quantum-framework 0.18.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 (333) hide show
  1. quantum/__init__.py +1 -0
  2. quantum/cli/__init__.py +2 -0
  3. quantum/cli/admin.py +131 -0
  4. quantum/cli/check.py +223 -0
  5. quantum/cli/commands/__init__.py +15 -0
  6. quantum/cli/commands/build.py +414 -0
  7. quantum/cli/commands/dev.py +185 -0
  8. quantum/cli/commands/docs.py +329 -0
  9. quantum/cli/commands/lint.py +523 -0
  10. quantum/cli/commands/migrate.py +548 -0
  11. quantum/cli/commands/new.py +622 -0
  12. quantum/cli/commands/serve.py +190 -0
  13. quantum/cli/commands/test.py +193 -0
  14. quantum/cli/deploy.py +810 -0
  15. quantum/cli/hot_reload.py +951 -0
  16. quantum/cli/jobs.py +356 -0
  17. quantum/cli/mq.py +582 -0
  18. quantum/cli/pkg.py +390 -0
  19. quantum/cli/runner.py +565 -0
  20. quantum/cli/server_process.py +159 -0
  21. quantum/cli/utils.py +334 -0
  22. quantum/compiler/__init__.py +30 -0
  23. quantum/compiler/base_generator.py +367 -0
  24. quantum/compiler/cli.py +295 -0
  25. quantum/compiler/expression_transformer.py +444 -0
  26. quantum/compiler/javascript/__init__.py +10 -0
  27. quantum/compiler/javascript/generator.py +659 -0
  28. quantum/compiler/optimizer.py +270 -0
  29. quantum/compiler/python/__init__.py +10 -0
  30. quantum/compiler/python/generator.py +883 -0
  31. quantum/compiler/python/runtime.py +863 -0
  32. quantum/compiler/transpiler.py +330 -0
  33. quantum/core/__init__.py +3 -0
  34. quantum/core/ast_nodes.py +2619 -0
  35. quantum/core/config_env.py +54 -0
  36. quantum/core/docs_links.py +8 -0
  37. quantum/core/expression_diagnostics.py +87 -0
  38. quantum/core/expression_stdlib.py +208 -0
  39. quantum/core/expressions.py +677 -0
  40. quantum/core/features/agents/src/__init__.py +30 -0
  41. quantum/core/features/agents/src/ast_node.py +540 -0
  42. quantum/core/features/conditionals/src/__init__.py +8 -0
  43. quantum/core/features/conditionals/src/ast_node.py +68 -0
  44. quantum/core/features/data_import/src/__init__.py +0 -0
  45. quantum/core/features/data_import/src/ast_node.py +292 -0
  46. quantum/core/features/data_import/src/runtime.py +533 -0
  47. quantum/core/features/dump/src/__init__.py +12 -0
  48. quantum/core/features/dump/src/ast_node.py +106 -0
  49. quantum/core/features/dump/src/parser.py +61 -0
  50. quantum/core/features/dump/src/runtime.py +246 -0
  51. quantum/core/features/functions/src/__init__.py +8 -0
  52. quantum/core/features/functions/src/ast_node.py +149 -0
  53. quantum/core/features/game_engine_2d/src/__init__.py +19 -0
  54. quantum/core/features/game_engine_2d/src/ast_nodes.py +1719 -0
  55. quantum/core/features/game_engine_2d/src/parser.py +983 -0
  56. quantum/core/features/invocation/src/__init__.py +0 -0
  57. quantum/core/features/invocation/src/ast_node.py +147 -0
  58. quantum/core/features/invocation/src/runtime.py +332 -0
  59. quantum/core/features/knowledge_base/src/__init__.py +6 -0
  60. quantum/core/features/knowledge_base/src/ast_node.py +113 -0
  61. quantum/core/features/knowledge_base/src/parser.py +82 -0
  62. quantum/core/features/logging/src/__init__.py +12 -0
  63. quantum/core/features/logging/src/ast_node.py +111 -0
  64. quantum/core/features/logging/src/parser.py +50 -0
  65. quantum/core/features/logging/src/runtime.py +190 -0
  66. quantum/core/features/loops/src/__init__.py +8 -0
  67. quantum/core/features/loops/src/ast_node.py +60 -0
  68. quantum/core/features/query/src/__init__.py +0 -0
  69. quantum/core/features/query/src/query_validators.py +20 -0
  70. quantum/core/features/state_management/src/__init__.py +11 -0
  71. quantum/core/features/state_management/src/ast_node.py +121 -0
  72. quantum/core/features/terminal_engine/src/__init__.py +21 -0
  73. quantum/core/features/terminal_engine/src/ast_nodes.py +560 -0
  74. quantum/core/features/terminal_engine/src/parser.py +361 -0
  75. quantum/core/features/testing_engine/src/__init__.py +41 -0
  76. quantum/core/features/testing_engine/src/ast_nodes.py +1212 -0
  77. quantum/core/features/testing_engine/src/parser.py +604 -0
  78. quantum/core/features/theming/src/__init__.py +48 -0
  79. quantum/core/features/theming/src/ast_node.py +137 -0
  80. quantum/core/features/theming/src/presets.py +405 -0
  81. quantum/core/features/ui_engine/src/__init__.py +20 -0
  82. quantum/core/features/ui_engine/src/ast_nodes.py +1913 -0
  83. quantum/core/features/ui_engine/src/parser.py +1192 -0
  84. quantum/core/features/websocket/src/__init__.py +24 -0
  85. quantum/core/features/websocket/src/ast_node.py +247 -0
  86. quantum/core/guards.py +53 -0
  87. quantum/core/html_compat.py +299 -0
  88. quantum/core/parser.py +1551 -0
  89. quantum/core/parser_registry.py +213 -0
  90. quantum/core/parsers/__init__.py +76 -0
  91. quantum/core/parsers/ai/__init__.py +12 -0
  92. quantum/core/parsers/ai/agent_parser.py +106 -0
  93. quantum/core/parsers/ai/knowledge_parser.py +86 -0
  94. quantum/core/parsers/ai/llm_parser.py +78 -0
  95. quantum/core/parsers/ai/team_parser.py +88 -0
  96. quantum/core/parsers/base.py +322 -0
  97. quantum/core/parsers/composition/__init__.py +10 -0
  98. quantum/core/parsers/composition/import_parser.py +50 -0
  99. quantum/core/parsers/composition/slot_parser.py +48 -0
  100. quantum/core/parsers/control_flow/__init__.py +11 -0
  101. quantum/core/parsers/control_flow/if_parser.py +71 -0
  102. quantum/core/parsers/control_flow/loop_parser.py +105 -0
  103. quantum/core/parsers/control_flow/set_parser.py +93 -0
  104. quantum/core/parsers/data/__init__.py +12 -0
  105. quantum/core/parsers/data/data_parser.py +229 -0
  106. quantum/core/parsers/data/invoke_parser.py +133 -0
  107. quantum/core/parsers/data/query_parser.py +213 -0
  108. quantum/core/parsers/data/transaction_parser.py +86 -0
  109. quantum/core/parsers/events/__init__.py +9 -0
  110. quantum/core/parsers/events/dispatch_event_parser.py +44 -0
  111. quantum/core/parsers/forms/__init__.py +11 -0
  112. quantum/core/parsers/forms/action_parser.py +79 -0
  113. quantum/core/parsers/forms/flash_parser.py +34 -0
  114. quantum/core/parsers/forms/redirect_parser.py +33 -0
  115. quantum/core/parsers/functions/__init__.py +11 -0
  116. quantum/core/parsers/functions/function_parser.py +98 -0
  117. quantum/core/parsers/functions/param_parser.py +73 -0
  118. quantum/core/parsers/functions/return_parser.py +31 -0
  119. quantum/core/parsers/html/__init__.py +10 -0
  120. quantum/core/parsers/html/component_call_parser.py +130 -0
  121. quantum/core/parsers/html/html_parser.py +115 -0
  122. quantum/core/parsers/jobs/__init__.py +11 -0
  123. quantum/core/parsers/jobs/job_parser.py +71 -0
  124. quantum/core/parsers/jobs/schedule_parser.py +62 -0
  125. quantum/core/parsers/jobs/thread_parser.py +57 -0
  126. quantum/core/parsers/messaging/__init__.py +17 -0
  127. quantum/core/parsers/messaging/message_ack_parser.py +30 -0
  128. quantum/core/parsers/messaging/message_nack_parser.py +30 -0
  129. quantum/core/parsers/messaging/message_parser.py +114 -0
  130. quantum/core/parsers/messaging/queue_parser.py +61 -0
  131. quantum/core/parsers/messaging/websocket_parser.py +121 -0
  132. quantum/core/parsers/routing/__init__.py +9 -0
  133. quantum/core/parsers/routing/route_parser.py +41 -0
  134. quantum/core/parsers/scripting/__init__.py +12 -0
  135. quantum/core/parsers/scripting/pyclass_parser.py +62 -0
  136. quantum/core/parsers/scripting/pydecorator_parser.py +68 -0
  137. quantum/core/parsers/scripting/pyimport_parser.py +52 -0
  138. quantum/core/parsers/scripting/python_parser.py +49 -0
  139. quantum/core/parsers/services/__init__.py +12 -0
  140. quantum/core/parsers/services/dump_parser.py +52 -0
  141. quantum/core/parsers/services/file_parser.py +48 -0
  142. quantum/core/parsers/services/log_parser.py +46 -0
  143. quantum/core/parsers/services/mail_parser.py +65 -0
  144. quantum/core/tiers.py +164 -0
  145. quantum/core/xml_lines.py +81 -0
  146. quantum/packages/__init__.py +28 -0
  147. quantum/packages/manager.py +413 -0
  148. quantum/packages/manifest.py +351 -0
  149. quantum/packages/registry.py +399 -0
  150. quantum/packages/resolver.py +336 -0
  151. quantum/plugins/__init__.py +33 -0
  152. quantum/plugins/hooks.py +329 -0
  153. quantum/plugins/loader.py +479 -0
  154. quantum/plugins/manifest.py +336 -0
  155. quantum/plugins/registry.py +371 -0
  156. quantum/runtime/__init__.py +25 -0
  157. quantum/runtime/action_handler.py +532 -0
  158. quantum/runtime/adapters/__init__.py +88 -0
  159. quantum/runtime/adapters/memory_adapter.py +690 -0
  160. quantum/runtime/adapters/rabbitmq_adapter.py +715 -0
  161. quantum/runtime/adapters/redis_adapter.py +582 -0
  162. quantum/runtime/adapters/sqlite_adapter.py +414 -0
  163. quantum/runtime/agent_service.py +1133 -0
  164. quantum/runtime/ast_cache.py +506 -0
  165. quantum/runtime/auth_service.py +267 -0
  166. quantum/runtime/component.py +1048 -0
  167. quantum/runtime/component_composer.py +133 -0
  168. quantum/runtime/component_resolver.py +174 -0
  169. quantum/runtime/conditions.py +54 -0
  170. quantum/runtime/database_service.py +595 -0
  171. quantum/runtime/db_schema.py +100 -0
  172. quantum/runtime/dev_panel.py +178 -0
  173. quantum/runtime/email_service.py +162 -0
  174. quantum/runtime/error_handler.py +295 -0
  175. quantum/runtime/execution_context.py +283 -0
  176. quantum/runtime/executor_registry.py +178 -0
  177. quantum/runtime/executors/__init__.py +71 -0
  178. quantum/runtime/executors/ai/__init__.py +12 -0
  179. quantum/runtime/executors/ai/agent_executor.py +217 -0
  180. quantum/runtime/executors/ai/knowledge_executor.py +114 -0
  181. quantum/runtime/executors/ai/llm_executor.py +153 -0
  182. quantum/runtime/executors/ai/team_executor.py +171 -0
  183. quantum/runtime/executors/base.py +264 -0
  184. quantum/runtime/executors/control_flow/__init__.py +12 -0
  185. quantum/runtime/executors/control_flow/if_executor.py +98 -0
  186. quantum/runtime/executors/control_flow/loop_executor.py +308 -0
  187. quantum/runtime/executors/control_flow/redirect_executor.py +43 -0
  188. quantum/runtime/executors/control_flow/set_executor.py +464 -0
  189. quantum/runtime/executors/data/__init__.py +12 -0
  190. quantum/runtime/executors/data/data_executor.py +156 -0
  191. quantum/runtime/executors/data/invoke_executor.py +228 -0
  192. quantum/runtime/executors/data/query_executor.py +290 -0
  193. quantum/runtime/executors/data/transaction_executor.py +91 -0
  194. quantum/runtime/executors/jobs/__init__.py +11 -0
  195. quantum/runtime/executors/jobs/job_executor.py +190 -0
  196. quantum/runtime/executors/jobs/schedule_executor.py +132 -0
  197. quantum/runtime/executors/jobs/thread_executor.py +127 -0
  198. quantum/runtime/executors/messaging/__init__.py +17 -0
  199. quantum/runtime/executors/messaging/message_ack_executor.py +51 -0
  200. quantum/runtime/executors/messaging/message_executor.py +174 -0
  201. quantum/runtime/executors/messaging/queue_executor.py +103 -0
  202. quantum/runtime/executors/messaging/websocket_executor.py +197 -0
  203. quantum/runtime/executors/scripting/__init__.py +11 -0
  204. quantum/runtime/executors/scripting/pyclass_executor.py +90 -0
  205. quantum/runtime/executors/scripting/pyimport_executor.py +81 -0
  206. quantum/runtime/executors/scripting/python_executor.py +249 -0
  207. quantum/runtime/executors/services/__init__.py +12 -0
  208. quantum/runtime/executors/services/dump_executor.py +72 -0
  209. quantum/runtime/executors/services/file_executor.py +89 -0
  210. quantum/runtime/executors/services/log_executor.py +77 -0
  211. quantum/runtime/executors/services/mail_executor.py +81 -0
  212. quantum/runtime/expression_cache.py +498 -0
  213. quantum/runtime/file_upload_service.py +326 -0
  214. quantum/runtime/function_registry.py +118 -0
  215. quantum/runtime/game_builder.py +166 -0
  216. quantum/runtime/game_code_generator.py +2371 -0
  217. quantum/runtime/game_templates.py +2006 -0
  218. quantum/runtime/godot_code_generator.py +4681 -0
  219. quantum/runtime/godot_templates.py +1449 -0
  220. quantum/runtime/job_executor.py +1599 -0
  221. quantum/runtime/knowledge_service.py +546 -0
  222. quantum/runtime/llm_cache.py +100 -0
  223. quantum/runtime/llm_providers.py +704 -0
  224. quantum/runtime/llm_service.py +287 -0
  225. quantum/runtime/local_server.py +28 -0
  226. quantum/runtime/logging_setup.py +141 -0
  227. quantum/runtime/message_broker.py +364 -0
  228. quantum/runtime/message_queue_service.py +571 -0
  229. quantum/runtime/param_validation.py +218 -0
  230. quantum/runtime/pypy_compat.py +317 -0
  231. quantum/runtime/python_bridge.py +698 -0
  232. quantum/runtime/query_validators.py +304 -0
  233. quantum/runtime/renderer.py +734 -0
  234. quantum/runtime/service_container.py +470 -0
  235. quantum/runtime/table_params.py +163 -0
  236. quantum/runtime/terminal_builder.py +76 -0
  237. quantum/runtime/terminal_code_generator.py +607 -0
  238. quantum/runtime/terminal_templates.py +243 -0
  239. quantum/runtime/testing_builder.py +77 -0
  240. quantum/runtime/testing_code_generator.py +833 -0
  241. quantum/runtime/testing_templates.py +85 -0
  242. quantum/runtime/ui_builder.py +222 -0
  243. quantum/runtime/ui_console.py +405 -0
  244. quantum/runtime/ui_desktop.py +56 -0
  245. quantum/runtime/ui_form_rules.py +193 -0
  246. quantum/runtime/ui_html_adapter.py +2567 -0
  247. quantum/runtime/ui_html_templates.py +2106 -0
  248. quantum/runtime/ui_mobile_adapter.py +1832 -0
  249. quantum/runtime/ui_mobile_templates.py +1003 -0
  250. quantum/runtime/ui_page_renderer.py +392 -0
  251. quantum/runtime/ui_pager.py +73 -0
  252. quantum/runtime/ui_table_edit.py +139 -0
  253. quantum/runtime/ui_textual_adapter.py +1866 -0
  254. quantum/runtime/ui_textual_templates.py +45 -0
  255. quantum/runtime/ui_tokens.py +465 -0
  256. quantum/runtime/ui_validator.py +365 -0
  257. quantum/runtime/ui_view.py +221 -0
  258. quantum/runtime/validators.py +256 -0
  259. quantum/runtime/web_server.py +2131 -0
  260. quantum/runtime/websocket_adapter.py +501 -0
  261. quantum/runtime/websocket_service.py +585 -0
  262. quantum/runtime/websocket_transport.py +289 -0
  263. quantum/runtime/wsgi.py +101 -0
  264. quantum/services.py +109 -0
  265. quantum/utils/__init__.py +1 -0
  266. quantum_admin/__init__.py +4 -0
  267. quantum_admin/assets/quantum-admin-connectors.js +123 -0
  268. quantum_admin/assets/quantum-admin.css +2058 -0
  269. quantum_admin/assets/quantum-source-highlight.js +106 -0
  270. quantum_admin/backend/__init__.py +4 -0
  271. quantum_admin/backend/audit_service.py +346 -0
  272. quantum_admin/backend/auth_service.py +394 -0
  273. quantum_admin/backend/component_discovery.py +766 -0
  274. quantum_admin/backend/config_generator.py +586 -0
  275. quantum_admin/backend/connector_service.py +1016 -0
  276. quantum_admin/backend/crud.py +557 -0
  277. quantum_admin/backend/database.py +226 -0
  278. quantum_admin/backend/db_setup_service.py +193 -0
  279. quantum_admin/backend/deploy_service.py +698 -0
  280. quantum_admin/backend/docker_service.py +422 -0
  281. quantum_admin/backend/environment_service.py +418 -0
  282. quantum_admin/backend/git_service.py +370 -0
  283. quantum_admin/backend/health_service.py +390 -0
  284. quantum_admin/backend/job_service.py +347 -0
  285. quantum_admin/backend/log_service.py +331 -0
  286. quantum_admin/backend/main.py +14426 -0
  287. quantum_admin/backend/models.py +986 -0
  288. quantum_admin/backend/pipeline_service.py +812 -0
  289. quantum_admin/backend/resource_manager.py +1118 -0
  290. quantum_admin/backend/schemas.py +419 -0
  291. quantum_admin/backend/secret_manager.py +277 -0
  292. quantum_admin/backend/settings_service.py +488 -0
  293. quantum_admin/backend/template_service.py +710 -0
  294. quantum_admin/backend/test_execution_service.py +364 -0
  295. quantum_admin/backend/test_generator.py +782 -0
  296. quantum_admin/backend/webhook_service.py +454 -0
  297. quantum_admin/backend/websocket_manager.py +360 -0
  298. quantum_admin/components/admin/_layout/AdminShell.q +121 -0
  299. quantum_admin/components/admin/agents.q +107 -0
  300. quantum_admin/components/admin/app/[name].q +558 -0
  301. quantum_admin/components/admin/applications.q +160 -0
  302. quantum_admin/components/admin/component/[...path].q +205 -0
  303. quantum_admin/components/admin/components.q +59 -0
  304. quantum_admin/components/admin/connectors.q +283 -0
  305. quantum_admin/components/admin/dashboard.q +86 -0
  306. quantum_admin/components/admin/database.q +95 -0
  307. quantum_admin/components/admin/features.q +69 -0
  308. quantum_admin/components/admin/index.q +12 -0
  309. quantum_admin/components/admin/jobs.q +75 -0
  310. quantum_admin/components/admin/login.q +65 -0
  311. quantum_admin/components/admin/logout.q +11 -0
  312. quantum_admin/components/admin/settings.q +174 -0
  313. quantum_admin/components/admin/source.q +45 -0
  314. quantum_admin/components/admin/tests.q +73 -0
  315. quantum_admin/run.py +13 -0
  316. quantum_admin/services/__init__.py +16 -0
  317. quantum_admin/services/_base.py +84 -0
  318. quantum_admin/services/_component_test_generator.py +205 -0
  319. quantum_admin/services/_yaml_editor.py +82 -0
  320. quantum_admin/services/apps.py +330 -0
  321. quantum_admin/services/auth.py +61 -0
  322. quantum_admin/services/components.py +210 -0
  323. quantum_admin/services/connectors.py +164 -0
  324. quantum_admin/services/projects.py +189 -0
  325. quantum_admin/services/repository.py +223 -0
  326. quantum_admin/services/settings.py +150 -0
  327. quantum_admin/services/yaml_import.py +118 -0
  328. quantum_framework-0.18.0.dist-info/METADATA +281 -0
  329. quantum_framework-0.18.0.dist-info/RECORD +333 -0
  330. quantum_framework-0.18.0.dist-info/WHEEL +5 -0
  331. quantum_framework-0.18.0.dist-info/entry_points.txt +2 -0
  332. quantum_framework-0.18.0.dist-info/licenses/LICENSE +21 -0
  333. quantum_framework-0.18.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,546 @@
1
+ """
2
+ Quantum Knowledge Service - RAG with ChromaDB + Ollama embeddings.
3
+
4
+ Provides indexing, vector search, and RAG query capabilities for
5
+ the q:knowledge / q:query knowledge: datasource integration.
6
+ """
7
+
8
+ import os
9
+ import re
10
+ import glob
11
+ import hashlib
12
+ import logging
13
+ from pathlib import Path
14
+ from typing import List, Dict, Any, Optional
15
+
16
+ import requests
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class KnowledgeError(Exception):
22
+ """Raised when knowledge base operations fail."""
23
+ pass
24
+
25
+
26
+ class KnowledgeService:
27
+ """ChromaDB + Ollama embeddings service for RAG."""
28
+
29
+ def __init__(self, llm_service=None):
30
+ self.llm_service = llm_service
31
+ self._collections: Dict[str, Any] = {} # name -> ChromaDB collection
32
+ self._client = None
33
+ self._ollama_base_url = os.getenv(
34
+ 'QUANTUM_LLM_BASE_URL', 'http://localhost:11434'
35
+ ).rstrip('/')
36
+
37
+ def _get_client(self, persist: bool = False, persist_path: Optional[str] = None):
38
+ """Get or create ChromaDB client."""
39
+ if self._client is not None:
40
+ return self._client
41
+
42
+ try:
43
+ import chromadb
44
+ except ImportError:
45
+ raise KnowledgeError(
46
+ "q:knowledge needs the optional RAG dependencies, which are not "
47
+ "installed.\n"
48
+ " pip install 'quantum-framework[rag]' (or: pip install chromadb)\n"
49
+ "You also need an embedding model available on your LLM server, "
50
+ "e.g. `ollama pull nomic-embed-text`."
51
+ )
52
+
53
+ if persist and persist_path:
54
+ self._client = chromadb.PersistentClient(path=persist_path)
55
+ else:
56
+ self._client = chromadb.Client()
57
+
58
+ return self._client
59
+
60
+ @staticmethod
61
+ def _collection_name(name: str) -> str:
62
+ """A ChromaDB collection name for any q:knowledge name.
63
+
64
+ ChromaDB accepts 3-512 characters from [a-zA-Z0-9._-], starting and
65
+ ending alphanumeric. `<q:knowledge name="kb">` failed with ChromaDB's
66
+ own validation message. The prefix makes every name long enough and
67
+ keeps Quantum's collections recognisable in a shared store.
68
+ """
69
+ safe = re.sub(r'[^a-zA-Z0-9._-]', '-', str(name)).strip('._-') or 'base'
70
+ return f"quantum-{safe}"[:512]
71
+
72
+ @staticmethod
73
+ def _fingerprint(all_texts, embed_model, chunk_size, chunk_overlap) -> str:
74
+ """What the index was built from: texts, their sources, and chunking."""
75
+ digest = hashlib.sha256()
76
+ digest.update(f"{embed_model}|{chunk_size}|{chunk_overlap}".encode())
77
+ for item in all_texts:
78
+ digest.update(str(item.get('source', '')).encode())
79
+ digest.update(b'\x00')
80
+ digest.update(str(item.get('text', '')).encode())
81
+ digest.update(b'\x00')
82
+ digest.update(f"{item.get('chunk_size')}|{item.get('chunk_overlap')}".encode())
83
+ return digest.hexdigest()
84
+
85
+ def index_knowledge(
86
+ self,
87
+ name: str,
88
+ sources: list,
89
+ embed_model: str = "nomic-embed-text",
90
+ chunk_size: int = 500,
91
+ chunk_overlap: int = 50,
92
+ persist: bool = False,
93
+ persist_path: Optional[str] = None,
94
+ rebuild: bool = False,
95
+ database_service=None,
96
+ exec_context=None,
97
+ ):
98
+ """
99
+ Index knowledge base sources into ChromaDB.
100
+
101
+ Args:
102
+ name: Knowledge base name (used as collection name)
103
+ sources: List of KnowledgeSourceNode objects
104
+ embed_model: Ollama embedding model name
105
+ chunk_size: Characters per chunk
106
+ chunk_overlap: Overlap between chunks
107
+ persist: Whether to persist ChromaDB to disk
108
+ persist_path: Path for ChromaDB persistence
109
+ rebuild: Force reindex even if collection exists
110
+ database_service: DatabaseService for query-type sources
111
+ exec_context: ExecutionContext for variable resolution
112
+ """
113
+ client = self._get_client(persist, persist_path)
114
+
115
+ # Extract text first: whether the stored index is still valid depends
116
+ # on what the sources say NOW. Reading them is cheap; embedding is the
117
+ # expensive part, and that is what an unchanged fingerprint skips.
118
+ all_texts = []
119
+ for source in sources:
120
+ texts = self._extract_source_text(
121
+ source, database_service, exec_context
122
+ )
123
+ all_texts.extend(texts)
124
+
125
+ fingerprint = self._fingerprint(all_texts, embed_model, chunk_size, chunk_overlap)
126
+
127
+ # Knowledge bases persist by default (./.quantum/knowledge). The stored
128
+ # collection used to be reused whenever it had any chunks — so a base
129
+ # named "docs" indexed last week answered from last week's text, and
130
+ # changing the q:source did nothing. Measured: inline sources declared
131
+ # in a new file came back as chunks of docs/index.md from an earlier
132
+ # run. Reuse only when the fingerprint matches.
133
+ if not rebuild:
134
+ try:
135
+ existing = client.get_collection(self._collection_name(name))
136
+ except Exception:
137
+ existing = None
138
+ if existing is not None:
139
+ if (existing.count() > 0
140
+ and (existing.metadata or {}).get("quantum_fingerprint") == fingerprint):
141
+ self._collections[name] = existing
142
+ logger.info(f"Knowledge base '{name}' already indexed ({existing.count()} chunks)")
143
+ return
144
+ logger.info(f"Knowledge base '{name}': sources changed, reindexing")
145
+ rebuild = True
146
+
147
+ if rebuild:
148
+ try:
149
+ client.delete_collection(self._collection_name(name))
150
+ except Exception:
151
+ pass
152
+
153
+ collection = client.get_or_create_collection(
154
+ name=self._collection_name(name),
155
+ metadata={"hnsw:space": "cosine", "quantum_fingerprint": fingerprint}
156
+ )
157
+
158
+ if not all_texts:
159
+ logger.warning(f"Knowledge base '{name}': no text extracted from sources")
160
+ self._collections[name] = collection
161
+ return
162
+
163
+ # Chunk all extracted texts
164
+ all_chunks = []
165
+ all_metadata = []
166
+ for text_item in all_texts:
167
+ text = text_item['text']
168
+ source_label = text_item.get('source', 'inline')
169
+ cs = text_item.get('chunk_size', chunk_size)
170
+ co = text_item.get('chunk_overlap', chunk_overlap)
171
+
172
+ chunks = self._chunk_text(text, cs, co)
173
+ for i, chunk in enumerate(chunks):
174
+ all_chunks.append(chunk)
175
+ all_metadata.append({
176
+ "source": source_label,
177
+ "chunk_index": i,
178
+ "total_chunks": len(chunks),
179
+ })
180
+
181
+ if not all_chunks:
182
+ self._collections[name] = collection
183
+ return
184
+
185
+ # Generate embeddings via Ollama
186
+ embeddings = self._generate_embeddings(all_chunks, embed_model)
187
+
188
+ # Generate IDs
189
+ ids = [
190
+ f"{name}_{hashlib.md5(chunk.encode()).hexdigest()[:12]}_{i}"
191
+ for i, chunk in enumerate(all_chunks)
192
+ ]
193
+
194
+ # Upsert into ChromaDB (batch to avoid limits)
195
+ batch_size = 100
196
+ for start in range(0, len(all_chunks), batch_size):
197
+ end = min(start + batch_size, len(all_chunks))
198
+ collection.upsert(
199
+ ids=ids[start:end],
200
+ documents=all_chunks[start:end],
201
+ embeddings=embeddings[start:end],
202
+ metadatas=all_metadata[start:end],
203
+ )
204
+
205
+ self._collections[name] = collection
206
+ logger.info(f"Knowledge base '{name}' indexed: {len(all_chunks)} chunks from {len(all_texts)} text segments")
207
+
208
+ def _extract_source_text(
209
+ self, source, database_service=None, exec_context=None
210
+ ) -> List[Dict[str, Any]]:
211
+ """
212
+ Extract text from a KnowledgeSourceNode.
213
+
214
+ Returns list of dicts: [{text, source, chunk_size?, chunk_overlap?}]
215
+ """
216
+ results = []
217
+ st = source.source_type
218
+ extra = {}
219
+ if source.chunk_size is not None:
220
+ extra['chunk_size'] = source.chunk_size
221
+ if source.chunk_overlap is not None:
222
+ extra['chunk_overlap'] = source.chunk_overlap
223
+
224
+ if st == 'text':
225
+ if source.content:
226
+ results.append({'text': source.content, 'source': 'inline', **extra})
227
+
228
+ elif st == 'file':
229
+ path = Path(source.path)
230
+ if path.exists() and path.is_file():
231
+ try:
232
+ text = path.read_text(encoding='utf-8')
233
+ results.append({'text': text, 'source': str(path), **extra})
234
+ except Exception as e:
235
+ logger.warning(f"Failed to read file {path}: {e}")
236
+ else:
237
+ logger.warning(f"Knowledge source file not found: {source.path}")
238
+
239
+ elif st == 'directory':
240
+ dir_path = Path(source.path)
241
+ pattern = source.pattern or '*.md'
242
+ if dir_path.exists() and dir_path.is_dir():
243
+ for file_path in sorted(dir_path.glob(pattern)):
244
+ if file_path.is_file():
245
+ try:
246
+ text = file_path.read_text(encoding='utf-8')
247
+ results.append({'text': text, 'source': str(file_path), **extra})
248
+ except Exception as e:
249
+ logger.warning(f"Failed to read {file_path}: {e}")
250
+ else:
251
+ logger.warning(f"Knowledge source directory not found: {source.path}")
252
+
253
+ elif st == 'url':
254
+ logger.warning("URL source type is not yet implemented for q:knowledge")
255
+
256
+ elif st == 'query':
257
+ if database_service and source.datasource and source.sql:
258
+ try:
259
+ result = database_service.execute_query(
260
+ source.datasource, source.sql, {}
261
+ )
262
+ for row in result.data:
263
+ # Concatenate all string values in the row
264
+ parts = [str(v) for v in row.values() if v is not None]
265
+ text = ' '.join(parts)
266
+ if text.strip():
267
+ results.append({'text': text, 'source': f"query:{source.datasource}", **extra})
268
+ except Exception as e:
269
+ logger.warning(f"Failed to execute knowledge source query: {e}")
270
+ else:
271
+ logger.warning("Query source requires datasource, sql, and database_service")
272
+
273
+ return results
274
+
275
+ def _chunk_text(self, text: str, chunk_size: int = 500, chunk_overlap: int = 50) -> List[str]:
276
+ """
277
+ Split text into chunks using a sliding window with paragraph/sentence awareness.
278
+
279
+ Args:
280
+ text: Input text
281
+ chunk_size: Target characters per chunk
282
+ chunk_overlap: Overlap between chunks
283
+
284
+ Returns:
285
+ List of text chunks
286
+ """
287
+ if not text or not text.strip():
288
+ return []
289
+
290
+ text = text.strip()
291
+
292
+ # If text is smaller than chunk_size, return as single chunk
293
+ if len(text) <= chunk_size:
294
+ return [text]
295
+
296
+ # Split into paragraphs first
297
+ paragraphs = re.split(r'\n\s*\n', text)
298
+
299
+ chunks = []
300
+ current_chunk = ""
301
+
302
+ for paragraph in paragraphs:
303
+ paragraph = paragraph.strip()
304
+ if not paragraph:
305
+ continue
306
+
307
+ # If adding this paragraph exceeds chunk_size
308
+ if current_chunk and len(current_chunk) + len(paragraph) + 1 > chunk_size:
309
+ chunks.append(current_chunk.strip())
310
+ # Start new chunk with overlap from end of previous
311
+ if chunk_overlap > 0 and len(current_chunk) > chunk_overlap:
312
+ current_chunk = current_chunk[-chunk_overlap:] + "\n" + paragraph
313
+ else:
314
+ current_chunk = paragraph
315
+ else:
316
+ if current_chunk:
317
+ current_chunk += "\n" + paragraph
318
+ else:
319
+ current_chunk = paragraph
320
+
321
+ # If single paragraph exceeds chunk_size, split by sentences
322
+ while len(current_chunk) > chunk_size:
323
+ # Find a good split point (end of sentence)
324
+ split_at = chunk_size
325
+ for sep in ['. ', '! ', '? ', '\n', '; ', ', ']:
326
+ idx = current_chunk.rfind(sep, 0, chunk_size)
327
+ if idx > chunk_size // 3:
328
+ split_at = idx + len(sep)
329
+ break
330
+
331
+ chunks.append(current_chunk[:split_at].strip())
332
+ if chunk_overlap > 0:
333
+ overlap_start = max(0, split_at - chunk_overlap)
334
+ current_chunk = current_chunk[overlap_start:]
335
+ else:
336
+ current_chunk = current_chunk[split_at:]
337
+
338
+ # Don't forget the last chunk
339
+ if current_chunk.strip():
340
+ chunks.append(current_chunk.strip())
341
+
342
+ return chunks
343
+
344
+ def _generate_embeddings(self, texts: List[str], model: str = "nomic-embed-text") -> List[List[float]]:
345
+ """
346
+ Generate embeddings via Ollama /api/embed endpoint.
347
+
348
+ Args:
349
+ texts: List of text strings to embed
350
+ model: Ollama embedding model name
351
+
352
+ Returns:
353
+ List of embedding vectors
354
+ """
355
+ if not texts:
356
+ return []
357
+
358
+ url = f"{self._ollama_base_url}/api/embed"
359
+
360
+ # Batch to avoid very large payloads
361
+ all_embeddings = []
362
+ batch_size = 50
363
+
364
+ for start in range(0, len(texts), batch_size):
365
+ batch = texts[start:start + batch_size]
366
+ payload = {
367
+ "model": model,
368
+ "input": batch,
369
+ }
370
+
371
+ try:
372
+ embed_timeout = int(os.getenv('QUANTUM_EMBED_TIMEOUT', '50'))
373
+ resp = requests.post(url, json=payload, timeout=embed_timeout)
374
+ resp.raise_for_status()
375
+ data = resp.json()
376
+
377
+ embeddings = data.get("embeddings", [])
378
+ if len(embeddings) != len(batch):
379
+ raise KnowledgeError(
380
+ f"Embedding count mismatch: expected {len(batch)}, got {len(embeddings)}"
381
+ )
382
+ all_embeddings.extend(embeddings)
383
+
384
+ except requests.ConnectionError:
385
+ raise KnowledgeError(
386
+ f"Cannot connect to Ollama at {self._ollama_base_url}. "
387
+ "Ensure Ollama is running (ollama serve) and the embedding model is pulled "
388
+ f"(ollama pull {model})"
389
+ )
390
+ except requests.Timeout:
391
+ raise KnowledgeError(f"Embedding request timed out for model {model}")
392
+ except requests.HTTPError as e:
393
+ raise KnowledgeError(f"Ollama embedding API error: {e.response.status_code} - {e.response.text}")
394
+ except KnowledgeError:
395
+ raise
396
+ except Exception as e:
397
+ raise KnowledgeError(f"Embedding generation error: {e}")
398
+
399
+ return all_embeddings
400
+
401
+ def search(
402
+ self,
403
+ name: str,
404
+ query_text: str,
405
+ n_results: int = 5,
406
+ embed_model: str = "nomic-embed-text",
407
+ ) -> List[Dict[str, Any]]:
408
+ """
409
+ Vector similarity search on a knowledge base.
410
+
411
+ Args:
412
+ name: Knowledge base name
413
+ query_text: Search query text
414
+ n_results: Number of results to return
415
+ embed_model: Embedding model to use for query
416
+
417
+ Returns:
418
+ List of dicts: [{content, relevance, source, chunk_index}]
419
+ """
420
+ collection = self._collections.get(name)
421
+ if collection is None:
422
+ raise KnowledgeError(f"Knowledge base '{name}' not found. Define it with <q:knowledge> first.")
423
+
424
+ if collection.count() == 0:
425
+ return []
426
+
427
+ # Generate query embedding
428
+ query_embedding = self._generate_embeddings([query_text], embed_model)
429
+ if not query_embedding:
430
+ return []
431
+
432
+ # Search ChromaDB
433
+ results = collection.query(
434
+ query_embeddings=query_embedding,
435
+ n_results=min(n_results, collection.count()),
436
+ include=["documents", "metadatas", "distances"],
437
+ )
438
+
439
+ # Format results
440
+ formatted = []
441
+ documents = results.get("documents", [[]])[0]
442
+ metadatas = results.get("metadatas", [[]])[0]
443
+ distances = results.get("distances", [[]])[0]
444
+
445
+ for doc, meta, dist in zip(documents, metadatas, distances):
446
+ # ChromaDB cosine distance: 0 = identical, 2 = opposite
447
+ # Convert to relevance score: 1.0 = perfect match, 0.0 = no match
448
+ relevance = max(0.0, 1.0 - dist / 2.0)
449
+ formatted.append({
450
+ "content": doc,
451
+ "relevance": round(relevance, 4),
452
+ "source": meta.get("source", "unknown"),
453
+ "chunk_index": meta.get("chunk_index", 0),
454
+ })
455
+
456
+ return formatted
457
+
458
+ def rag_query(
459
+ self,
460
+ name: str,
461
+ question: str,
462
+ model: Optional[str] = None,
463
+ n_results: int = 5,
464
+ embed_model: str = "nomic-embed-text",
465
+ ) -> Dict[str, Any]:
466
+ """
467
+ Full RAG pipeline: search + LLM answer.
468
+
469
+ Args:
470
+ name: Knowledge base name
471
+ question: User question
472
+ model: LLM model for answer generation
473
+ n_results: Number of chunks to retrieve for context
474
+ embed_model: Embedding model for search
475
+
476
+ Returns:
477
+ Dict: {answer, sources, confidence}
478
+ """
479
+ if self.llm_service is None:
480
+ raise KnowledgeError("LLM service is required for RAG queries. Ensure Ollama is available.")
481
+
482
+ # Step 1: Vector search
483
+ search_results = self.search(name, question, n_results, embed_model)
484
+
485
+ if not search_results:
486
+ return {
487
+ "answer": "No relevant information found in the knowledge base.",
488
+ "sources": [],
489
+ "confidence": 0.0,
490
+ }
491
+
492
+ # Step 2: Build context from search results
493
+ context_parts = []
494
+ sources = []
495
+ for i, result in enumerate(search_results):
496
+ context_parts.append(f"[{i+1}] {result['content']}")
497
+ src = result.get('source', 'unknown')
498
+ if src not in sources:
499
+ sources.append(src)
500
+
501
+ context = "\n\n".join(context_parts)
502
+
503
+ # Step 3: Generate answer via LLM
504
+ system_prompt = (
505
+ "You are a helpful assistant that answers questions based on the provided context. "
506
+ "Only use information from the context below. If the context doesn't contain "
507
+ "enough information to answer, say so. Be concise and accurate."
508
+ )
509
+
510
+ prompt = (
511
+ f"Context:\n{context}\n\n"
512
+ f"Question: {question}\n\n"
513
+ f"Answer:"
514
+ )
515
+
516
+ try:
517
+ llm_result = self.llm_service.generate(
518
+ prompt=prompt,
519
+ model=model,
520
+ system=system_prompt,
521
+ temperature=0.3,
522
+ )
523
+
524
+ answer = llm_result.get("data", "")
525
+ success = llm_result.get("success", False)
526
+ except Exception as e:
527
+ logger.error(f"RAG query failed for '{name}': {e}")
528
+ # Raised, not returned. This used to come back as the ANSWER —
529
+ # "Error generating answer: LLM request timed out after 60s", with
530
+ # confidence 0 — so a page rendered the failure as if the model
531
+ # had said it.
532
+ raise KnowledgeError(f"RAG answer for '{name}' failed: {e}") from e
533
+
534
+ if not success or not answer:
535
+ raise KnowledgeError(
536
+ f"RAG answer for '{name}' failed: "
537
+ f"{llm_result.get('error') or 'the model returned no answer'}")
538
+
539
+ # Calculate confidence based on search relevance
540
+ avg_relevance = sum(r['relevance'] for r in search_results) / len(search_results) if search_results else 0.0
541
+
542
+ return {
543
+ "answer": answer.strip(),
544
+ "sources": sources,
545
+ "confidence": round(avg_relevance, 4),
546
+ }
@@ -0,0 +1,100 @@
1
+ """
2
+ Quantum LLM Cache - TTL cache for q:llm responses.
3
+
4
+ q:llm has carried a `cache` attribute since it was written, wired to nothing.
5
+ LLM calls are the slowest and most expensive thing a Quantum component can do,
6
+ so a cache is not a nicety — a repeated prompt during development costs seconds
7
+ and, on a paid provider, money.
8
+
9
+ Keyed on everything that can change the answer: provider, model, endpoint, the
10
+ full message list (or prompt+system), temperature, max_tokens and the requested
11
+ response format. Two calls that differ in any of those are different calls.
12
+ """
13
+
14
+ import hashlib
15
+ import json
16
+ import threading
17
+ import time
18
+ from typing import Any, Dict, Optional
19
+
20
+
21
+ class LLMCache:
22
+ """In-process TTL cache for LLM responses."""
23
+
24
+ def __init__(self, max_entries: int = 500):
25
+ self._entries: Dict[str, Dict[str, Any]] = {}
26
+ self._lock = threading.Lock()
27
+ self.max_entries = max_entries
28
+ self.hits = 0
29
+ self.misses = 0
30
+
31
+ @staticmethod
32
+ def build_key(**parts: Any) -> str:
33
+ """Build a stable key from the call's distinguishing parameters."""
34
+ payload = json.dumps(parts, sort_keys=True, default=str)
35
+ return hashlib.sha256(payload.encode('utf-8')).hexdigest()
36
+
37
+ def get(self, key: str) -> Optional[Dict[str, Any]]:
38
+ """Return a cached response, or None if absent or expired."""
39
+ with self._lock:
40
+ entry = self._entries.get(key)
41
+ if entry is None:
42
+ self.misses += 1
43
+ return None
44
+
45
+ expires_at = entry['expires_at']
46
+ if expires_at is not None and time.time() > expires_at:
47
+ del self._entries[key]
48
+ self.misses += 1
49
+ return None
50
+
51
+ self.hits += 1
52
+ # Copy so a caller mutating the result cannot poison the cache
53
+ return dict(entry['value'])
54
+
55
+ def set(self, key: str, value: Dict[str, Any], ttl: Optional[int] = None):
56
+ """Store a response. ttl in seconds; None means no expiry."""
57
+ with self._lock:
58
+ if len(self._entries) >= self.max_entries:
59
+ # Evict the entry closest to expiry (None sorts last)
60
+ oldest = min(
61
+ self._entries.items(),
62
+ key=lambda kv: (kv[1]['expires_at'] is None, kv[1]['expires_at'] or 0),
63
+ )
64
+ del self._entries[oldest[0]]
65
+
66
+ self._entries[key] = {
67
+ 'value': dict(value),
68
+ 'expires_at': (time.time() + ttl) if ttl else None,
69
+ }
70
+
71
+ def clear(self):
72
+ with self._lock:
73
+ self._entries.clear()
74
+
75
+ def stats(self) -> Dict[str, Any]:
76
+ with self._lock:
77
+ total = self.hits + self.misses
78
+ return {
79
+ 'entries': len(self._entries),
80
+ 'hits': self.hits,
81
+ 'misses': self.misses,
82
+ 'hitRate': round(self.hits / total, 4) if total else 0.0,
83
+ }
84
+
85
+
86
+ _llm_cache: Optional[LLMCache] = None
87
+
88
+
89
+ def get_llm_cache() -> LLMCache:
90
+ """Get the process-wide LLM cache."""
91
+ global _llm_cache
92
+ if _llm_cache is None:
93
+ _llm_cache = LLMCache()
94
+ return _llm_cache
95
+
96
+
97
+ def reset_llm_cache():
98
+ """Reset the global cache (for tests)."""
99
+ global _llm_cache
100
+ _llm_cache = None