htm 0.0.15 → 0.0.18

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 (135) hide show
  1. checksums.yaml +4 -4
  2. data/.architecture/decisions/adrs/001-use-postgresql-timescaledb-storage.md +1 -1
  3. data/.architecture/decisions/adrs/011-database-side-embedding-generation-with-pgai.md +4 -4
  4. data/.architecture/decisions/adrs/012-llm-driven-ontology-topic-extraction.md +1 -1
  5. data/.envrc +12 -24
  6. data/.irbrc +7 -7
  7. data/.tbls.yml +2 -2
  8. data/CHANGELOG.md +138 -0
  9. data/README.md +97 -1592
  10. data/Rakefile +8 -3
  11. data/SETUP.md +12 -12
  12. data/bin/htm_mcp +27 -0
  13. data/db/seed_data/README.md +2 -2
  14. data/db/seeds.rb +2 -2
  15. data/docs/api/database.md +37 -37
  16. data/docs/api/htm.md +1 -1
  17. data/docs/api/yard/HTM/ActiveRecordConfig.md +2 -2
  18. data/docs/api/yard/HTM/Configuration.md +26 -15
  19. data/docs/api/yard/HTM/Database.md +7 -8
  20. data/docs/api/yard/HTM/JobAdapter.md +1 -1
  21. data/docs/api/yard/HTM/Railtie.md +2 -2
  22. data/docs/architecture/adrs/001-postgresql-timescaledb.md +1 -1
  23. data/docs/architecture/adrs/011-pgai-integration.md +4 -4
  24. data/docs/database_rake_tasks.md +5 -5
  25. data/docs/development/rake-tasks.md +11 -11
  26. data/docs/development/setup.md +21 -21
  27. data/docs/development/testing.md +1 -1
  28. data/docs/getting-started/installation.md +51 -31
  29. data/docs/getting-started/quick-start.md +12 -12
  30. data/docs/guides/getting-started.md +2 -2
  31. data/docs/guides/long-term-memory.md +1 -1
  32. data/docs/guides/mcp-server.md +464 -29
  33. data/docs/guides/robot-groups.md +8 -8
  34. data/docs/index.md +4 -4
  35. data/docs/multi_framework_support.md +10 -10
  36. data/docs/setup_local_database.md +19 -19
  37. data/docs/using_rake_tasks_in_your_app.md +14 -14
  38. data/examples/README.md +50 -6
  39. data/examples/basic_usage.rb +31 -21
  40. data/examples/cli_app/README.md +8 -8
  41. data/examples/cli_app/htm_cli.rb +5 -5
  42. data/examples/config_file_example/README.md +256 -0
  43. data/examples/config_file_example/config/htm.local.yml +34 -0
  44. data/examples/config_file_example/custom_config.yml +22 -0
  45. data/examples/config_file_example/show_config.rb +125 -0
  46. data/examples/custom_llm_configuration.rb +7 -7
  47. data/examples/example_app/Rakefile +2 -2
  48. data/examples/example_app/app.rb +8 -8
  49. data/examples/file_loader_usage.rb +9 -9
  50. data/examples/mcp_client.rb +7 -7
  51. data/examples/rails_app/.gitignore +2 -0
  52. data/examples/rails_app/Gemfile +22 -0
  53. data/examples/rails_app/Gemfile.lock +430 -0
  54. data/examples/rails_app/Procfile.dev +1 -0
  55. data/examples/rails_app/README.md +98 -0
  56. data/examples/rails_app/Rakefile +5 -0
  57. data/examples/rails_app/app/assets/stylesheets/application.css +83 -0
  58. data/examples/rails_app/app/assets/stylesheets/inter-font.css +6 -0
  59. data/examples/rails_app/app/controllers/application_controller.rb +19 -0
  60. data/examples/rails_app/app/controllers/dashboard_controller.rb +27 -0
  61. data/examples/rails_app/app/controllers/files_controller.rb +205 -0
  62. data/examples/rails_app/app/controllers/memories_controller.rb +102 -0
  63. data/examples/rails_app/app/controllers/robots_controller.rb +44 -0
  64. data/examples/rails_app/app/controllers/search_controller.rb +46 -0
  65. data/examples/rails_app/app/controllers/tags_controller.rb +30 -0
  66. data/examples/rails_app/app/javascript/application.js +4 -0
  67. data/examples/rails_app/app/javascript/controllers/application.js +9 -0
  68. data/examples/rails_app/app/javascript/controllers/index.js +6 -0
  69. data/examples/rails_app/app/views/dashboard/index.html.erb +123 -0
  70. data/examples/rails_app/app/views/files/index.html.erb +108 -0
  71. data/examples/rails_app/app/views/files/new.html.erb +321 -0
  72. data/examples/rails_app/app/views/files/show.html.erb +130 -0
  73. data/examples/rails_app/app/views/layouts/application.html.erb +124 -0
  74. data/examples/rails_app/app/views/memories/_memory_card.html.erb +51 -0
  75. data/examples/rails_app/app/views/memories/deleted.html.erb +62 -0
  76. data/examples/rails_app/app/views/memories/edit.html.erb +35 -0
  77. data/examples/rails_app/app/views/memories/index.html.erb +81 -0
  78. data/examples/rails_app/app/views/memories/new.html.erb +71 -0
  79. data/examples/rails_app/app/views/memories/show.html.erb +126 -0
  80. data/examples/rails_app/app/views/robots/index.html.erb +106 -0
  81. data/examples/rails_app/app/views/robots/new.html.erb +36 -0
  82. data/examples/rails_app/app/views/robots/show.html.erb +79 -0
  83. data/examples/rails_app/app/views/search/index.html.erb +184 -0
  84. data/examples/rails_app/app/views/shared/_navbar.html.erb +52 -0
  85. data/examples/rails_app/app/views/shared/_stat_card.html.erb +52 -0
  86. data/examples/rails_app/app/views/tags/index.html.erb +131 -0
  87. data/examples/rails_app/app/views/tags/show.html.erb +67 -0
  88. data/examples/rails_app/bin/dev +8 -0
  89. data/examples/rails_app/bin/rails +4 -0
  90. data/examples/rails_app/bin/rake +4 -0
  91. data/examples/rails_app/config/application.rb +33 -0
  92. data/examples/rails_app/config/boot.rb +5 -0
  93. data/examples/rails_app/config/database.yml +15 -0
  94. data/examples/rails_app/config/environment.rb +5 -0
  95. data/examples/rails_app/config/importmap.rb +7 -0
  96. data/examples/rails_app/config/routes.rb +38 -0
  97. data/examples/rails_app/config/tailwind.config.js +35 -0
  98. data/examples/rails_app/config.ru +5 -0
  99. data/examples/rails_app/log/.keep +0 -0
  100. data/examples/rails_app/tmp/local_secret.txt +1 -0
  101. data/examples/robot_groups/multi_process.rb +5 -5
  102. data/examples/robot_groups/robot_worker.rb +5 -5
  103. data/examples/robot_groups/same_process.rb +9 -9
  104. data/examples/sinatra_app/app.rb +1 -1
  105. data/examples/timeframe_demo.rb +1 -1
  106. data/lib/htm/active_record_config.rb +12 -28
  107. data/lib/htm/circuit_breaker.rb +0 -2
  108. data/lib/htm/config/defaults.yml +246 -0
  109. data/lib/htm/config.rb +888 -0
  110. data/lib/htm/database.rb +26 -33
  111. data/lib/htm/embedding_service.rb +0 -4
  112. data/lib/htm/integrations/sinatra.rb +3 -7
  113. data/lib/htm/job_adapter.rb +1 -15
  114. data/lib/htm/jobs/generate_embedding_job.rb +1 -7
  115. data/lib/htm/jobs/generate_propositions_job.rb +2 -12
  116. data/lib/htm/jobs/generate_tags_job.rb +1 -8
  117. data/lib/htm/loaders/defaults_loader.rb +143 -0
  118. data/lib/htm/loaders/xdg_config_loader.rb +116 -0
  119. data/lib/htm/mcp/cli.rb +475 -0
  120. data/lib/htm/mcp/group_tools.rb +476 -0
  121. data/lib/htm/mcp/resources.rb +89 -0
  122. data/lib/htm/mcp/server.rb +98 -0
  123. data/lib/htm/mcp/tools.rb +488 -0
  124. data/lib/htm/models/file_source.rb +5 -3
  125. data/lib/htm/proposition_service.rb +2 -12
  126. data/lib/htm/railtie.rb +3 -8
  127. data/lib/htm/tag_service.rb +1 -8
  128. data/lib/htm/tasks.rb +7 -4
  129. data/lib/htm/version.rb +1 -1
  130. data/lib/htm.rb +124 -5
  131. data/lib/tasks/htm.rake +6 -9
  132. metadata +81 -6
  133. data/bin/htm_mcp.rb +0 -621
  134. data/config/database.yml +0 -74
  135. data/lib/htm/configuration.rb +0 -766
@@ -1,766 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'errors'
4
- require 'logger'
5
-
6
- class HTM
7
- # HTM Configuration
8
- #
9
- # HTM uses RubyLLM for multi-provider LLM support. Supported providers:
10
- # - :openai (OpenAI API)
11
- # - :anthropic (Anthropic Claude)
12
- # - :gemini (Google Gemini)
13
- # - :azure (Azure OpenAI)
14
- # - :ollama (Local Ollama - default)
15
- # - :huggingface (HuggingFace Inference API)
16
- # - :openrouter (OpenRouter)
17
- # - :bedrock (AWS Bedrock)
18
- # - :deepseek (DeepSeek)
19
- #
20
- # @example Configure with OpenAI
21
- # HTM.configure do |config|
22
- # config.embedding_provider = :openai
23
- # config.embedding_model = 'text-embedding-3-small'
24
- # config.tag_provider = :openai
25
- # config.tag_model = 'gpt-4o-mini'
26
- # config.openai_api_key = ENV['OPENAI_API_KEY']
27
- # end
28
- #
29
- # @example Configure with Ollama (default)
30
- # HTM.configure do |config|
31
- # config.embedding_provider = :ollama
32
- # config.embedding_model = 'nomic-embed-text'
33
- # config.tag_provider = :ollama
34
- # config.tag_model = 'llama3'
35
- # config.ollama_url = 'http://localhost:11434'
36
- # end
37
- #
38
- # @example Configure with Anthropic for tags, OpenAI for embeddings
39
- # HTM.configure do |config|
40
- # config.embedding_provider = :openai
41
- # config.embedding_model = 'text-embedding-3-small'
42
- # config.openai_api_key = ENV['OPENAI_API_KEY']
43
- # config.tag_provider = :anthropic
44
- # config.tag_model = 'claude-3-haiku-20240307'
45
- # config.anthropic_api_key = ENV['ANTHROPIC_API_KEY']
46
- # end
47
- #
48
- # @example Configure with custom methods
49
- # HTM.configure do |config|
50
- # config.embedding_generator = ->(text) {
51
- # MyApp::LLMService.embed(text) # Returns Array<Float>
52
- # }
53
- # config.tag_extractor = ->(text, ontology) {
54
- # MyApp::LLMService.extract_tags(text, ontology) # Returns Array<String>
55
- # }
56
- # config.logger = Rails.logger
57
- # end
58
- #
59
- class Configuration
60
- attr_accessor :embedding_generator, :tag_extractor, :proposition_extractor, :token_counter
61
- attr_accessor :embedding_model, :embedding_provider, :embedding_dimensions
62
- attr_accessor :tag_model, :tag_provider
63
- attr_accessor :proposition_model, :proposition_provider, :extract_propositions
64
- attr_accessor :embedding_timeout, :tag_timeout, :proposition_timeout, :connection_timeout
65
- attr_accessor :logger
66
- attr_accessor :job_backend
67
- attr_accessor :week_start
68
- attr_accessor :telemetry_enabled # Enable OpenTelemetry metrics (default: false)
69
-
70
- # Limit configuration
71
- attr_accessor :max_embedding_dimension # Max vector dimensions (default: 2000)
72
- attr_accessor :max_tag_depth # Max tag hierarchy depth (default: 4)
73
-
74
- # Chunking configuration (for file loading)
75
- attr_accessor :chunk_size # Max characters per chunk (default: 1024)
76
- attr_accessor :chunk_overlap # Character overlap between chunks (default: 64)
77
-
78
- # Circuit breaker configuration
79
- attr_accessor :circuit_breaker_failure_threshold # Failures before opening (default: 5)
80
- attr_accessor :circuit_breaker_reset_timeout # Seconds before half-open (default: 60)
81
- attr_accessor :circuit_breaker_half_open_max_calls # Successes to close (default: 3)
82
-
83
- # Relevance scoring weights (must sum to 1.0)
84
- attr_accessor :relevance_semantic_weight # Vector similarity weight (default: 0.5)
85
- attr_accessor :relevance_tag_weight # Tag overlap weight (default: 0.3)
86
- attr_accessor :relevance_recency_weight # Temporal freshness weight (default: 0.1)
87
- attr_accessor :relevance_access_weight # Access frequency weight (default: 0.1)
88
- attr_accessor :relevance_recency_half_life_hours # Decay half-life in hours (default: 168 = 1 week)
89
-
90
- # Provider-specific API keys and endpoints
91
- attr_accessor :openai_api_key, :openai_organization, :openai_project
92
- attr_accessor :anthropic_api_key
93
- attr_accessor :gemini_api_key
94
- attr_accessor :azure_api_key, :azure_endpoint, :azure_api_version
95
- attr_accessor :ollama_url
96
- attr_accessor :huggingface_api_key
97
- attr_accessor :openrouter_api_key
98
- attr_accessor :bedrock_access_key, :bedrock_secret_key, :bedrock_region
99
- attr_accessor :deepseek_api_key
100
-
101
- # Supported providers
102
- SUPPORTED_PROVIDERS = %i[
103
- openai anthropic gemini azure ollama
104
- huggingface openrouter bedrock deepseek
105
- ].freeze
106
-
107
- # Default embedding dimensions by provider/model
108
- DEFAULT_DIMENSIONS = {
109
- openai: 1536, # text-embedding-3-small
110
- anthropic: 1024, # voyage embeddings
111
- gemini: 768, # text-embedding-004
112
- azure: 1536, # same as OpenAI
113
- ollama: 768, # nomic-embed-text
114
- huggingface: 768, # varies by model
115
- openrouter: 1536, # varies by model
116
- bedrock: 1536, # titan-embed-text
117
- deepseek: 1536 # varies by model
118
- }.freeze
119
-
120
- def initialize
121
- # Default configuration - Ollama for local development
122
- # All settings can be overridden via HTM_* environment variables
123
- @embedding_provider = ENV.fetch('HTM_EMBEDDING_PROVIDER', 'ollama').to_sym
124
- @embedding_model = ENV.fetch('HTM_EMBEDDING_MODEL', 'nomic-embed-text:latest')
125
- @embedding_dimensions = ENV.fetch('HTM_EMBEDDING_DIMENSIONS', 768).to_i
126
-
127
- @tag_provider = ENV.fetch('HTM_TAG_PROVIDER', 'ollama').to_sym
128
- @tag_model = ENV.fetch('HTM_TAG_MODEL', 'gemma3:latest')
129
-
130
- @proposition_provider = ENV.fetch('HTM_PROPOSITION_PROVIDER', 'ollama').to_sym
131
- @proposition_model = ENV.fetch('HTM_PROPOSITION_MODEL', 'gemma3:latest')
132
- @extract_propositions = ENV.fetch('HTM_EXTRACT_PROPOSITIONS', 'false').downcase == 'true'
133
-
134
- # Provider credentials from environment variables
135
- # These use standard provider env var names for compatibility
136
- @openai_api_key = ENV.fetch('HTM_OPENAI_API_KEY', ENV['OPENAI_API_KEY'])
137
- @openai_organization = ENV.fetch('HTM_OPENAI_ORGANIZATION', ENV['OPENAI_ORGANIZATION'])
138
- @openai_project = ENV.fetch('HTM_OPENAI_PROJECT', ENV['OPENAI_PROJECT'])
139
- @anthropic_api_key = ENV.fetch('HTM_ANTHROPIC_API_KEY', ENV['ANTHROPIC_API_KEY'])
140
- @gemini_api_key = ENV.fetch('HTM_GEMINI_API_KEY', ENV['GEMINI_API_KEY'])
141
- @azure_api_key = ENV.fetch('HTM_AZURE_API_KEY', ENV['AZURE_OPENAI_API_KEY'])
142
- @azure_endpoint = ENV.fetch('HTM_AZURE_ENDPOINT', ENV['AZURE_OPENAI_ENDPOINT'])
143
- @azure_api_version = ENV.fetch('HTM_AZURE_API_VERSION', ENV.fetch('AZURE_OPENAI_API_VERSION', '2024-02-01'))
144
- @ollama_url = ENV.fetch('HTM_OLLAMA_URL', ENV['OLLAMA_API_BASE'] || ENV['OLLAMA_URL'] || 'http://localhost:11434')
145
- @huggingface_api_key = ENV.fetch('HTM_HUGGINGFACE_API_KEY', ENV['HUGGINGFACE_API_KEY'])
146
- @openrouter_api_key = ENV.fetch('HTM_OPENROUTER_API_KEY', ENV['OPENROUTER_API_KEY'])
147
- @bedrock_access_key = ENV.fetch('HTM_BEDROCK_ACCESS_KEY', ENV['AWS_ACCESS_KEY_ID'])
148
- @bedrock_secret_key = ENV.fetch('HTM_BEDROCK_SECRET_KEY', ENV['AWS_SECRET_ACCESS_KEY'])
149
- @bedrock_region = ENV.fetch('HTM_BEDROCK_REGION', ENV.fetch('AWS_REGION', 'us-east-1'))
150
- @deepseek_api_key = ENV.fetch('HTM_DEEPSEEK_API_KEY', ENV['DEEPSEEK_API_KEY'])
151
-
152
- # Timeout settings (in seconds) - apply to all LLM providers
153
- @embedding_timeout = ENV.fetch('HTM_EMBEDDING_TIMEOUT', 120).to_i
154
- @tag_timeout = ENV.fetch('HTM_TAG_TIMEOUT', 180).to_i
155
- @proposition_timeout = ENV.fetch('HTM_PROPOSITION_TIMEOUT', 180).to_i
156
- @connection_timeout = ENV.fetch('HTM_CONNECTION_TIMEOUT', 30).to_i
157
-
158
- # Limit settings
159
- @max_embedding_dimension = ENV.fetch('HTM_MAX_EMBEDDING_DIMENSION', 2000).to_i
160
- @max_tag_depth = ENV.fetch('HTM_MAX_TAG_DEPTH', 4).to_i
161
-
162
- # Chunking settings (for file loading)
163
- @chunk_size = ENV.fetch('HTM_CHUNK_SIZE', 1024).to_i
164
- @chunk_overlap = ENV.fetch('HTM_CHUNK_OVERLAP', 64).to_i
165
-
166
- # Circuit breaker settings
167
- @circuit_breaker_failure_threshold = ENV.fetch('HTM_CIRCUIT_BREAKER_FAILURE_THRESHOLD', 5).to_i
168
- @circuit_breaker_reset_timeout = ENV.fetch('HTM_CIRCUIT_BREAKER_RESET_TIMEOUT', 60).to_i
169
- @circuit_breaker_half_open_max_calls = ENV.fetch('HTM_CIRCUIT_BREAKER_HALF_OPEN_MAX_CALLS', 3).to_i
170
-
171
- # Relevance scoring weights (should sum to 1.0)
172
- @relevance_semantic_weight = ENV.fetch('HTM_RELEVANCE_SEMANTIC_WEIGHT', 0.5).to_f
173
- @relevance_tag_weight = ENV.fetch('HTM_RELEVANCE_TAG_WEIGHT', 0.3).to_f
174
- @relevance_recency_weight = ENV.fetch('HTM_RELEVANCE_RECENCY_WEIGHT', 0.1).to_f
175
- @relevance_access_weight = ENV.fetch('HTM_RELEVANCE_ACCESS_WEIGHT', 0.1).to_f
176
- @relevance_recency_half_life_hours = ENV.fetch('HTM_RELEVANCE_RECENCY_HALF_LIFE_HOURS', 168.0).to_f
177
-
178
- # Default logger (STDOUT with INFO level)
179
- @logger = default_logger
180
-
181
- # Job backend: inline, thread, active_job, sidekiq (auto-detected if not set)
182
- @job_backend = ENV['HTM_JOB_BACKEND'] ? ENV['HTM_JOB_BACKEND'].to_sym : detect_job_backend
183
-
184
- # Timeframe parsing configuration: sunday or monday
185
- @week_start = ENV.fetch('HTM_WEEK_START', 'sunday').to_sym
186
-
187
- # Telemetry (OpenTelemetry metrics)
188
- @telemetry_enabled = ENV.fetch('HTM_TELEMETRY_ENABLED', 'false').downcase == 'true'
189
-
190
- # Thread-safe Ollama model refresh tracking
191
- @ollama_models_refreshed = false
192
- @ollama_refresh_mutex = Mutex.new
193
-
194
- # Set default implementations
195
- reset_to_defaults
196
- end
197
-
198
- # Reset to default RubyLLM-based implementations
199
- def reset_to_defaults
200
- @embedding_generator = default_embedding_generator
201
- @tag_extractor = default_tag_extractor
202
- @proposition_extractor = default_proposition_extractor
203
- @token_counter = default_token_counter
204
- end
205
-
206
- # Validate configuration
207
- def validate!
208
- unless @embedding_generator.respond_to?(:call)
209
- raise HTM::ValidationError, "embedding_generator must be callable (proc, lambda, or object responding to :call)"
210
- end
211
-
212
- unless @tag_extractor.respond_to?(:call)
213
- raise HTM::ValidationError, "tag_extractor must be callable (proc, lambda, or object responding to :call)"
214
- end
215
-
216
- unless @proposition_extractor.respond_to?(:call)
217
- raise HTM::ValidationError, "proposition_extractor must be callable (proc, lambda, or object responding to :call)"
218
- end
219
-
220
- unless @token_counter.respond_to?(:call)
221
- raise HTM::ValidationError, "token_counter must be callable (proc, lambda, or object responding to :call)"
222
- end
223
-
224
- unless @logger.respond_to?(:info) && @logger.respond_to?(:warn) && @logger.respond_to?(:error)
225
- raise HTM::ValidationError, "logger must respond to :info, :warn, and :error"
226
- end
227
-
228
- unless [:active_job, :sidekiq, :inline, :thread].include?(@job_backend)
229
- raise HTM::ValidationError, "job_backend must be one of: :active_job, :sidekiq, :inline, :thread (got #{@job_backend.inspect})"
230
- end
231
-
232
- unless [:sunday, :monday].include?(@week_start)
233
- raise HTM::ValidationError, "week_start must be :sunday or :monday (got #{@week_start.inspect})"
234
- end
235
-
236
- # Validate provider if specified
237
- if @embedding_provider && !SUPPORTED_PROVIDERS.include?(@embedding_provider)
238
- raise HTM::ValidationError, "embedding_provider must be one of: #{SUPPORTED_PROVIDERS.join(', ')} (got #{@embedding_provider.inspect})"
239
- end
240
-
241
- if @tag_provider && !SUPPORTED_PROVIDERS.include?(@tag_provider)
242
- raise HTM::ValidationError, "tag_provider must be one of: #{SUPPORTED_PROVIDERS.join(', ')} (got #{@tag_provider.inspect})"
243
- end
244
-
245
- if @proposition_provider && !SUPPORTED_PROVIDERS.include?(@proposition_provider)
246
- raise HTM::ValidationError, "proposition_provider must be one of: #{SUPPORTED_PROVIDERS.join(', ')} (got #{@proposition_provider.inspect})"
247
- end
248
- end
249
-
250
- # Normalize Ollama model name to include tag if missing
251
- #
252
- # Ollama models require a tag (e.g., :latest, :7b, :13b). If the user
253
- # specifies a model without a tag, we append :latest by default.
254
- #
255
- # @param model_name [String] Original model name
256
- # @return [String] Normalized model name with tag
257
- #
258
- def normalize_ollama_model(model_name)
259
- return model_name if model_name.nil? || model_name.empty?
260
- return model_name if model_name.include?(':')
261
-
262
- "#{model_name}:latest"
263
- end
264
-
265
- # Configure RubyLLM with the appropriate provider credentials
266
- #
267
- # @param provider [Symbol] The provider to configure (:openai, :anthropic, etc.)
268
- #
269
- def configure_ruby_llm(provider = nil)
270
- # Always require ruby_llm to ensure full module is loaded
271
- # (require is idempotent, and defined?(RubyLLM) can be true before configure method exists)
272
- require 'ruby_llm'
273
-
274
- provider ||= @embedding_provider
275
-
276
- RubyLLM.configure do |config|
277
- case provider
278
- when :openai
279
- config.openai_api_key = @openai_api_key if @openai_api_key
280
- config.openai_organization = @openai_organization if @openai_organization && config.respond_to?(:openai_organization=)
281
- config.openai_project = @openai_project if @openai_project && config.respond_to?(:openai_project=)
282
- when :anthropic
283
- config.anthropic_api_key = @anthropic_api_key if @anthropic_api_key
284
- when :gemini
285
- config.gemini_api_key = @gemini_api_key if @gemini_api_key
286
- when :azure
287
- config.azure_api_key = @azure_api_key if @azure_api_key && config.respond_to?(:azure_api_key=)
288
- config.azure_endpoint = @azure_endpoint if @azure_endpoint && config.respond_to?(:azure_endpoint=)
289
- config.azure_api_version = @azure_api_version if @azure_api_version && config.respond_to?(:azure_api_version=)
290
- when :ollama
291
- # Ollama exposes OpenAI-compatible API at /v1
292
- # Ensure URL has /v1 suffix (add if missing, don't duplicate if present)
293
- ollama_api_base = if @ollama_url.end_with?('/v1') || @ollama_url.end_with?('/v1/')
294
- @ollama_url.sub(%r{/+$}, '') # Just remove trailing slashes
295
- else
296
- "#{@ollama_url.sub(%r{/+$}, '')}/v1"
297
- end
298
- config.ollama_api_base = ollama_api_base
299
- when :huggingface
300
- config.huggingface_api_key = @huggingface_api_key if @huggingface_api_key && config.respond_to?(:huggingface_api_key=)
301
- when :openrouter
302
- config.openrouter_api_key = @openrouter_api_key if @openrouter_api_key && config.respond_to?(:openrouter_api_key=)
303
- when :bedrock
304
- config.bedrock_api_key = @bedrock_access_key if @bedrock_access_key && config.respond_to?(:bedrock_api_key=)
305
- config.bedrock_secret_key = @bedrock_secret_key if @bedrock_secret_key && config.respond_to?(:bedrock_secret_key=)
306
- config.bedrock_region = @bedrock_region if @bedrock_region && config.respond_to?(:bedrock_region=)
307
- when :deepseek
308
- config.deepseek_api_key = @deepseek_api_key if @deepseek_api_key && config.respond_to?(:deepseek_api_key=)
309
- end
310
- end
311
- end
312
-
313
- private
314
-
315
- # Auto-detect appropriate job backend based on environment
316
- #
317
- # Detection priority:
318
- # 1. ActiveJob (if defined) - Rails applications
319
- # 2. Sidekiq (if defined) - Sinatra and other web apps
320
- # 3. Inline (if test environment) - Test suites
321
- # 4. Thread (default fallback) - CLI and standalone apps
322
- #
323
- # @return [Symbol] Detected job backend
324
- #
325
- def detect_job_backend
326
- # Check for explicit environment variable override
327
- if ENV['HTM_JOB_BACKEND']
328
- return ENV['HTM_JOB_BACKEND'].to_sym
329
- end
330
-
331
- # Detect test environment - use inline for synchronous execution
332
- test_env = ENV['RACK_ENV'] == 'test' || ENV['RAILS_ENV'] == 'test' || ENV['APP_ENV'] == 'test'
333
- return :inline if test_env
334
-
335
- # Detect Rails - prefer ActiveJob
336
- if defined?(ActiveJob)
337
- return :active_job
338
- end
339
-
340
- # Detect Sidekiq - direct integration for Sinatra apps
341
- if defined?(Sidekiq)
342
- return :sidekiq
343
- end
344
-
345
- # Default fallback - simple threading for standalone/CLI apps
346
- :thread
347
- end
348
-
349
- # Default logger configuration
350
- def default_logger
351
- logger = Logger.new($stdout)
352
- logger.level = ENV.fetch('HTM_LOG_LEVEL', 'INFO').upcase.to_sym
353
- logger.formatter = proc do |severity, datetime, progname, msg|
354
- "[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} -- HTM: #{msg}\n"
355
- end
356
- logger
357
- end
358
-
359
- # Default token counter using Tiktoken
360
- def default_token_counter
361
- lambda do |text|
362
- require 'tiktoken_ruby' unless defined?(Tiktoken)
363
- encoder = Tiktoken.encoding_for_model("gpt-3.5-turbo")
364
- encoder.encode(text).length
365
- end
366
- end
367
-
368
- # Default embedding generator using RubyLLM
369
- #
370
- # @return [Proc] Callable that takes text and returns embedding vector
371
- #
372
- def default_embedding_generator
373
- lambda do |text|
374
- require 'ruby_llm' unless defined?(RubyLLM)
375
-
376
- # Configure RubyLLM for the embedding provider
377
- configure_ruby_llm(@embedding_provider)
378
-
379
- # Refresh models for Ollama to discover local models (thread-safe)
380
- if @embedding_provider == :ollama
381
- @ollama_refresh_mutex.synchronize do
382
- unless @ollama_models_refreshed
383
- RubyLLM.models.refresh!
384
- @ollama_models_refreshed = true
385
- end
386
- end
387
- end
388
-
389
- # Normalize Ollama model name (ensure it has a tag like :latest)
390
- model = @embedding_provider == :ollama ? normalize_ollama_model(@embedding_model) : @embedding_model
391
-
392
- # Generate embedding using RubyLLM
393
- response = RubyLLM.embed(text, model: model)
394
-
395
- # Extract embedding vector from response
396
- embedding = extract_embedding_from_response(response)
397
-
398
- unless embedding.is_a?(Array) && embedding.all? { |v| v.is_a?(Numeric) }
399
- raise HTM::EmbeddingError, "Invalid embedding response format from #{@embedding_provider}"
400
- end
401
-
402
- embedding
403
- end
404
- end
405
-
406
- # Extract embedding vector from RubyLLM response
407
- #
408
- # @param response [Object] RubyLLM embed response
409
- # @return [Array<Float>] Embedding vector
410
- #
411
- def extract_embedding_from_response(response)
412
- return nil unless response
413
-
414
- # Handle different response formats from RubyLLM
415
- case response
416
- when Array
417
- # Direct array response
418
- response
419
- when ->(r) { r.respond_to?(:vectors) }
420
- # RubyLLM::Embedding object with vectors method
421
- vectors = response.vectors
422
- vectors.is_a?(Array) && vectors.first.is_a?(Array) ? vectors.first : vectors
423
- when ->(r) { r.respond_to?(:to_a) }
424
- # Can be converted to array
425
- response.to_a
426
- when ->(r) { r.respond_to?(:embedding) }
427
- # Has embedding attribute
428
- response.embedding
429
- else
430
- # Try to extract vectors from instance variables
431
- if response.respond_to?(:instance_variable_get)
432
- vectors = response.instance_variable_get(:@vectors)
433
- return vectors.first if vectors.is_a?(Array) && vectors.first.is_a?(Array)
434
- return vectors if vectors.is_a?(Array)
435
- end
436
- raise HTM::EmbeddingError, "Cannot extract embedding from response: #{response.class}"
437
- end
438
- end
439
-
440
- # Default tag extractor using RubyLLM chat
441
- #
442
- # @return [Proc] Callable that takes text and ontology, returns array of tags
443
- #
444
- def default_tag_extractor
445
- lambda do |text, existing_ontology = []|
446
- require 'ruby_llm' unless defined?(RubyLLM)
447
-
448
- # Configure RubyLLM for the tag provider
449
- configure_ruby_llm(@tag_provider)
450
-
451
- # Refresh models for Ollama to discover local models (thread-safe)
452
- if @tag_provider == :ollama
453
- @ollama_refresh_mutex.synchronize do
454
- unless @ollama_models_refreshed
455
- RubyLLM.models.refresh!
456
- @ollama_models_refreshed = true
457
- end
458
- end
459
- end
460
-
461
- # Normalize Ollama model name (ensure it has a tag like :latest)
462
- model = @tag_provider == :ollama ? normalize_ollama_model(@tag_model) : @tag_model
463
-
464
- # Build prompt
465
- taxonomy_context = if existing_ontology.any?
466
- sample_tags = existing_ontology.sample([existing_ontology.size, 20].min)
467
- "Existing taxonomy paths: #{sample_tags.join(', ')}\n\nPrefer reusing these paths when the text matches their domain."
468
- else
469
- "This is a new taxonomy - establish clear root categories."
470
- end
471
-
472
- prompt = <<~PROMPT
473
- Extract classification tags for this text using a HIERARCHICAL TAXONOMY.
474
-
475
- A hierarchical taxonomy is a tree where each concept has exactly ONE parent path:
476
-
477
- domain
478
- ├── category
479
- │ ├── subcategory
480
- │ │ └── specific-term
481
- │ └── subcategory
482
- └── category
483
-
484
- #{taxonomy_context}
485
-
486
- TAG FORMAT: domain:category:subcategory:term (colon-separated, max 4 levels)
487
-
488
- LEVEL GUIDELINES:
489
- - Level 1 (domain): Broad field (database, ai, web, security, devops)
490
- - Level 2 (category): Major subdivision (database:relational, ai:machine-learning)
491
- - Level 3 (subcategory): Specific area (database:relational:postgresql)
492
- - Level 4 (term): Fine detail, use sparingly (database:relational:postgresql:extensions)
493
-
494
- RULES:
495
- 1. Each concept belongs to ONE path only (no duplicates across branches)
496
- 2. Use lowercase, hyphens for multi-word terms (natural-language-processing)
497
- 3. Return 2-5 tags that best classify this text
498
- 4. Match existing taxonomy paths when applicable
499
- 5. More general tags are often better than overly specific ones
500
-
501
- GOOD EXAMPLES:
502
- - database:postgresql
503
- - ai:machine-learning:embeddings
504
- - web:api:rest
505
- - programming:ruby:gems
506
-
507
- BAD EXAMPLES:
508
- - postgresql (missing domain - where does it belong?)
509
- - database:postgresql AND data:storage:postgresql (duplicate concept)
510
- - ai:ml:nlp:transformers:bert:embeddings (too deep)
511
-
512
- TEXT: #{text}
513
-
514
- Return ONLY tags, one per line.
515
- PROMPT
516
-
517
- system_prompt = <<~SYSTEM.strip
518
- You are a taxonomy classifier that assigns texts to a hierarchical classification tree.
519
-
520
- Core principle: Each concept has ONE canonical location in the tree. If "postgresql" exists under "database", never create it elsewhere.
521
-
522
- Your task:
523
- 1. Identify the domains/topics present in the text
524
- 2. Build paths from general (root) to specific (leaf)
525
- 3. Reuse existing taxonomy branches when they fit
526
- 4. Output 2-5 classification paths, one per line
527
- SYSTEM
528
-
529
- # Use RubyLLM chat for tag extraction
530
- chat = RubyLLM.chat(model: model)
531
- chat.with_instructions(system_prompt)
532
- response = chat.ask(prompt)
533
-
534
- # Extract text from response
535
- response_text = extract_text_from_response(response)
536
-
537
- # Parse and validate tags
538
- tags = response_text.to_s.split("\n").map(&:strip).reject(&:empty?)
539
-
540
- # Validate format: lowercase alphanumeric + hyphens + colons
541
- valid_tags = tags.select do |tag|
542
- tag =~ /^[a-z0-9\-]+(:[a-z0-9\-]+)*$/
543
- end
544
-
545
- # Limit depth to 4 levels (3 colons maximum)
546
- valid_tags.select { |tag| tag.count(':') < 4 }
547
- end
548
- end
549
-
550
- # Default proposition extractor using RubyLLM chat
551
- #
552
- # @return [Proc] Callable that takes text and returns array of propositions
553
- #
554
- def default_proposition_extractor
555
- lambda do |text|
556
- require 'ruby_llm' unless defined?(RubyLLM)
557
-
558
- # Configure RubyLLM for the proposition provider
559
- configure_ruby_llm(@proposition_provider)
560
-
561
- # Refresh models for Ollama to discover local models (thread-safe)
562
- if @proposition_provider == :ollama
563
- @ollama_refresh_mutex.synchronize do
564
- unless @ollama_models_refreshed
565
- RubyLLM.models.refresh!
566
- @ollama_models_refreshed = true
567
- end
568
- end
569
- end
570
-
571
- # Normalize Ollama model name (ensure it has a tag like :latest)
572
- model = @proposition_provider == :ollama ? normalize_ollama_model(@proposition_model) : @proposition_model
573
-
574
- # Build prompt
575
- prompt = <<~PROMPT
576
- Extract all ATOMIC factual propositions from the following text.
577
-
578
- An atomic proposition expresses exactly ONE relationship or fact. If a statement combines multiple pieces of information (what, where, when, who, why), split it into separate propositions.
579
-
580
- CRITICAL: Each proposition must contain only ONE of these:
581
- - ONE subject-verb relationship
582
- - ONE attribute or property
583
- - ONE location, time, or qualifier
584
-
585
- Example input: "Todd Warren plans to pursue a PhD in Music at the University of Texas."
586
-
587
- CORRECT atomic output:
588
- - Todd Warren plans to pursue a PhD.
589
- - Todd Warren plans to study Music.
590
- - Todd Warren plans to attend the University of Texas.
591
- - The University of Texas offers a PhD program in Music.
592
-
593
- WRONG (not atomic - combines multiple facts):
594
- - Todd Warren plans to pursue a PhD in Music at the University of Texas.
595
-
596
- Example input: "In 1969, Neil Armstrong became the first person to walk on the Moon during the Apollo 11 mission."
597
-
598
- CORRECT atomic output:
599
- - Neil Armstrong was an astronaut.
600
- - Neil Armstrong walked on the Moon.
601
- - Neil Armstrong walked on the Moon in 1969.
602
- - Neil Armstrong was the first person to walk on the Moon.
603
- - The Apollo 11 mission occurred in 1969.
604
- - Neil Armstrong participated in the Apollo 11 mission.
605
-
606
- Rules:
607
- 1. Split compound statements into separate atomic facts
608
- 2. Each proposition = exactly one fact
609
- 3. Use full names, never pronouns
610
- 4. Make each proposition understandable in isolation
611
- 5. Prefer more propositions over fewer
612
-
613
- TEXT: #{text}
614
-
615
- Return ONLY atomic propositions, one per line. Use a dash (-) prefix for each.
616
- PROMPT
617
-
618
- system_prompt = <<~SYSTEM.strip
619
- You are an atomic fact extraction system. Your goal is maximum decomposition.
620
-
621
- IMPORTANT: Break every statement into its smallest possible factual units.
622
-
623
- A statement like "John bought a red car in Paris" contains FOUR facts:
624
- - John bought a car.
625
- - The car John bought is red.
626
- - John made a purchase in Paris.
627
- - John bought a car in Paris.
628
-
629
- Always ask: "Can this be split further?" If yes, split it.
630
-
631
- Rules:
632
- 1. ONE fact per proposition (subject-predicate or subject-attribute)
633
- 2. Never combine location + action + time in one proposition
634
- 3. Never combine multiple attributes in one proposition
635
- 4. Use full names, never pronouns
636
- 5. Each proposition must stand alone without context
637
-
638
- Output ONLY propositions, one per line, prefixed with a dash (-).
639
- SYSTEM
640
-
641
- # Use RubyLLM chat for proposition extraction
642
- chat = RubyLLM.chat(model: model)
643
- chat.with_instructions(system_prompt)
644
- response = chat.ask(prompt)
645
-
646
- # Extract text from response
647
- response_text = extract_text_from_response(response)
648
-
649
- # Parse propositions (remove dash prefix, filter empty lines)
650
- response_text.to_s
651
- .split("\n")
652
- .map(&:strip)
653
- .map { |line| line.sub(/^[-*•]\s*/, '') }
654
- .map(&:strip)
655
- .reject(&:empty?)
656
- end
657
- end
658
-
659
- # Extract text content from RubyLLM chat response
660
- #
661
- # @param response [Object] RubyLLM chat response
662
- # @return [String] Response text
663
- #
664
- def extract_text_from_response(response)
665
- return '' unless response
666
-
667
- case response
668
- when String
669
- response
670
- when ->(r) { r.respond_to?(:content) }
671
- response.content.to_s
672
- when ->(r) { r.respond_to?(:text) }
673
- response.text.to_s
674
- when ->(r) { r.respond_to?(:to_s) }
675
- response.to_s
676
- else
677
- ''
678
- end
679
- end
680
- end
681
-
682
- class << self
683
- attr_writer :configuration
684
-
685
- # Get current configuration
686
- #
687
- # @return [HTM::Configuration]
688
- #
689
- def configuration
690
- @configuration ||= Configuration.new
691
- end
692
-
693
- # Configure HTM
694
- #
695
- # @yield [config] Configuration object
696
- # @yieldparam config [HTM::Configuration]
697
- #
698
- # @example Custom configuration
699
- # HTM.configure do |config|
700
- # config.embedding_generator = ->(text) { MyEmbedder.embed(text) }
701
- # config.tag_extractor = ->(text, ontology) { MyTagger.extract(text, ontology) }
702
- # end
703
- #
704
- # @example Default configuration
705
- # HTM.configure # Uses RubyLLM defaults
706
- #
707
- def configure
708
- yield(configuration) if block_given?
709
- configuration.validate!
710
- configuration
711
- end
712
-
713
- # Reset configuration to defaults
714
- def reset_configuration!
715
- @configuration = Configuration.new
716
- end
717
-
718
- # Generate embedding using EmbeddingService
719
- #
720
- # @param text [String] Text to embed
721
- # @return [Array<Float>] Embedding vector (original, not padded)
722
- #
723
- def embed(text)
724
- result = HTM::EmbeddingService.generate(text)
725
- result[:embedding]
726
- end
727
-
728
- # Extract tags using TagService
729
- #
730
- # @param text [String] Text to analyze
731
- # @param existing_ontology [Array<String>] Sample of existing tags for context
732
- # @return [Array<String>] Extracted and validated tag names
733
- #
734
- def extract_tags(text, existing_ontology: [])
735
- HTM::TagService.extract(text, existing_ontology: existing_ontology)
736
- end
737
-
738
- # Extract propositions using PropositionService
739
- #
740
- # @param text [String] Text to analyze
741
- # @return [Array<String>] Extracted atomic propositions
742
- #
743
- def extract_propositions(text)
744
- HTM::PropositionService.extract(text)
745
- end
746
-
747
- # Count tokens using configured counter
748
- #
749
- # @param text [String] Text to count tokens for
750
- # @return [Integer] Token count
751
- #
752
- def count_tokens(text)
753
- configuration.token_counter.call(text)
754
- rescue StandardError => e
755
- raise HTM::ValidationError, "Token counting failed: #{e.message}"
756
- end
757
-
758
- # Get configured logger
759
- #
760
- # @return [Logger] Configured logger instance
761
- #
762
- def logger
763
- configuration.logger
764
- end
765
- end
766
- end