htm 0.0.1 → 0.0.2
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.
- checksums.yaml +4 -4
- data/.envrc +1 -0
- data/.tbls.yml +30 -0
- data/CHANGELOG.md +30 -0
- data/SETUP.md +132 -101
- data/db/migrate/20250125000001_add_content_hash_to_nodes.rb +14 -0
- data/db/migrate/20250125000002_create_robot_nodes.rb +35 -0
- data/db/migrate/20250125000003_remove_source_and_robot_id_from_nodes.rb +28 -0
- data/db/migrate/20250126000001_create_working_memories.rb +19 -0
- data/db/migrate/20250126000002_remove_unused_columns.rb +12 -0
- data/db/schema.sql +226 -43
- data/docs/api/database.md +20 -232
- data/docs/api/embedding-service.md +1 -7
- data/docs/api/htm.md +195 -449
- data/docs/api/index.md +1 -7
- data/docs/api/long-term-memory.md +342 -590
- data/docs/architecture/adrs/001-postgresql-timescaledb.md +1 -1
- data/docs/architecture/adrs/003-ollama-embeddings.md +1 -1
- data/docs/architecture/adrs/010-redis-working-memory-rejected.md +2 -27
- data/docs/architecture/adrs/index.md +2 -13
- data/docs/architecture/hive-mind.md +165 -166
- data/docs/architecture/index.md +2 -2
- data/docs/architecture/overview.md +5 -171
- data/docs/architecture/two-tier-memory.md +1 -35
- data/docs/assets/images/adr-010-current-architecture.svg +37 -0
- data/docs/assets/images/adr-010-proposed-architecture.svg +48 -0
- data/docs/assets/images/adr-dependency-tree.svg +93 -0
- data/docs/assets/images/class-hierarchy.svg +55 -0
- data/docs/assets/images/exception-hierarchy.svg +45 -0
- data/docs/assets/images/htm-architecture-overview.svg +83 -0
- data/docs/assets/images/htm-complete-memory-flow.svg +160 -0
- data/docs/assets/images/htm-context-assembly-flow.svg +148 -0
- data/docs/assets/images/htm-eviction-process.svg +141 -0
- data/docs/assets/images/htm-memory-addition-flow.svg +138 -0
- data/docs/assets/images/htm-memory-recall-flow.svg +152 -0
- data/docs/assets/images/htm-node-states.svg +123 -0
- data/docs/assets/images/project-structure.svg +78 -0
- data/docs/assets/images/test-directory-structure.svg +38 -0
- data/{dbdoc → docs/database}/README.md +5 -3
- data/{dbdoc → docs/database}/public.node_tags.md +4 -5
- data/docs/database/public.node_tags.svg +106 -0
- data/{dbdoc → docs/database}/public.nodes.md +3 -8
- data/docs/database/public.nodes.svg +152 -0
- data/docs/database/public.robot_nodes.md +44 -0
- data/docs/database/public.robot_nodes.svg +121 -0
- data/{dbdoc → docs/database}/public.robots.md +1 -2
- data/docs/database/public.robots.svg +106 -0
- data/docs/database/public.working_memories.md +40 -0
- data/docs/database/public.working_memories.svg +112 -0
- data/{dbdoc → docs/database}/schema.json +342 -110
- data/docs/database/schema.svg +223 -0
- data/docs/development/index.md +1 -29
- data/docs/development/schema.md +84 -324
- data/docs/development/testing.md +1 -9
- data/docs/getting-started/index.md +47 -0
- data/docs/{installation.md → getting-started/installation.md} +2 -2
- data/docs/{quick-start.md → getting-started/quick-start.md} +5 -5
- data/docs/guides/adding-memories.md +221 -655
- data/docs/guides/search-strategies.md +85 -51
- data/docs/images/htm-er-diagram.svg +156 -0
- data/docs/index.md +16 -31
- data/docs/multi_framework_support.md +4 -4
- data/examples/basic_usage.rb +18 -16
- data/examples/cli_app/htm_cli.rb +86 -8
- data/examples/custom_llm_configuration.rb +1 -2
- data/examples/example_app/app.rb +11 -14
- data/examples/sinatra_app/Gemfile +1 -0
- data/examples/sinatra_app/Gemfile.lock +166 -0
- data/examples/sinatra_app/app.rb +219 -24
- data/lib/htm/active_record_config.rb +10 -3
- data/lib/htm/configuration.rb +265 -78
- data/lib/htm/{sinatra.rb → integrations/sinatra.rb} +87 -12
- data/lib/htm/job_adapter.rb +10 -3
- data/lib/htm/long_term_memory.rb +220 -57
- data/lib/htm/models/node.rb +36 -7
- data/lib/htm/models/robot.rb +30 -4
- data/lib/htm/models/robot_node.rb +50 -0
- data/lib/htm/models/tag.rb +52 -0
- data/lib/htm/models/working_memory_entry.rb +88 -0
- data/lib/htm/tasks.rb +4 -0
- data/lib/htm/version.rb +1 -1
- data/lib/htm.rb +34 -13
- data/lib/tasks/htm.rake +32 -1
- data/lib/tasks/jobs.rake +7 -3
- data/lib/tasks/tags.rake +34 -0
- data/mkdocs.yml +56 -9
- metadata +61 -31
- data/dbdoc/public.node_tags.svg +0 -112
- data/dbdoc/public.nodes.svg +0 -118
- data/dbdoc/public.robots.svg +0 -90
- data/dbdoc/schema.svg +0 -154
- /data/{dbdoc → docs/database}/public.node_stats.md +0 -0
- /data/{dbdoc → docs/database}/public.node_stats.svg +0 -0
- /data/{dbdoc → docs/database}/public.nodes_tags.md +0 -0
- /data/{dbdoc → docs/database}/public.nodes_tags.svg +0 -0
- /data/{dbdoc → docs/database}/public.ontology_structure.md +0 -0
- /data/{dbdoc → docs/database}/public.ontology_structure.svg +0 -0
- /data/{dbdoc → docs/database}/public.operations_log.md +0 -0
- /data/{dbdoc → docs/database}/public.operations_log.svg +0 -0
- /data/{dbdoc → docs/database}/public.relationships.md +0 -0
- /data/{dbdoc → docs/database}/public.relationships.svg +0 -0
- /data/{dbdoc → docs/database}/public.robot_activity.md +0 -0
- /data/{dbdoc → docs/database}/public.robot_activity.svg +0 -0
- /data/{dbdoc → docs/database}/public.schema_migrations.md +0 -0
- /data/{dbdoc → docs/database}/public.schema_migrations.svg +0 -0
- /data/{dbdoc → docs/database}/public.tags.md +0 -0
- /data/{dbdoc → docs/database}/public.tags.svg +0 -0
- /data/{dbdoc → docs/database}/public.topic_relationships.md +0 -0
- /data/{dbdoc → docs/database}/public.topic_relationships.svg +0 -0
data/lib/htm/configuration.rb
CHANGED
|
@@ -6,10 +6,44 @@ require 'logger'
|
|
|
6
6
|
class HTM
|
|
7
7
|
# HTM Configuration
|
|
8
8
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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
|
|
13
47
|
#
|
|
14
48
|
# @example Configure with custom methods
|
|
15
49
|
# HTM.configure do |config|
|
|
@@ -19,39 +53,72 @@ class HTM
|
|
|
19
53
|
# config.tag_extractor = ->(text, ontology) {
|
|
20
54
|
# MyApp::LLMService.extract_tags(text, ontology) # Returns Array<String>
|
|
21
55
|
# }
|
|
22
|
-
# config.logger = Rails.logger
|
|
23
|
-
# end
|
|
24
|
-
#
|
|
25
|
-
# @example Use defaults with custom timeouts
|
|
26
|
-
# HTM.configure do |config|
|
|
27
|
-
# config.embedding_timeout = 60 # 1 minute for faster models
|
|
28
|
-
# config.tag_timeout = 300 # 5 minutes for larger models
|
|
29
|
-
# config.connection_timeout = 10 # 10 seconds connection timeout
|
|
30
|
-
# config.reset_to_defaults # Apply default implementations with new timeouts
|
|
56
|
+
# config.logger = Rails.logger
|
|
31
57
|
# end
|
|
32
58
|
#
|
|
33
|
-
# @example Use defaults
|
|
34
|
-
# HTM.configure # Uses default implementations
|
|
35
|
-
#
|
|
36
59
|
class Configuration
|
|
37
60
|
attr_accessor :embedding_generator, :tag_extractor, :token_counter
|
|
38
61
|
attr_accessor :embedding_model, :embedding_provider, :embedding_dimensions
|
|
39
62
|
attr_accessor :tag_model, :tag_provider
|
|
40
|
-
attr_accessor :ollama_url
|
|
41
63
|
attr_accessor :embedding_timeout, :tag_timeout, :connection_timeout
|
|
42
64
|
attr_accessor :logger
|
|
43
65
|
attr_accessor :job_backend
|
|
44
66
|
|
|
67
|
+
# Provider-specific API keys and endpoints
|
|
68
|
+
attr_accessor :openai_api_key, :openai_organization, :openai_project
|
|
69
|
+
attr_accessor :anthropic_api_key
|
|
70
|
+
attr_accessor :gemini_api_key
|
|
71
|
+
attr_accessor :azure_api_key, :azure_endpoint, :azure_api_version
|
|
72
|
+
attr_accessor :ollama_url
|
|
73
|
+
attr_accessor :huggingface_api_key
|
|
74
|
+
attr_accessor :openrouter_api_key
|
|
75
|
+
attr_accessor :bedrock_access_key, :bedrock_secret_key, :bedrock_region
|
|
76
|
+
attr_accessor :deepseek_api_key
|
|
77
|
+
|
|
78
|
+
# Supported providers
|
|
79
|
+
SUPPORTED_PROVIDERS = %i[
|
|
80
|
+
openai anthropic gemini azure ollama
|
|
81
|
+
huggingface openrouter bedrock deepseek
|
|
82
|
+
].freeze
|
|
83
|
+
|
|
84
|
+
# Default embedding dimensions by provider/model
|
|
85
|
+
DEFAULT_DIMENSIONS = {
|
|
86
|
+
openai: 1536, # text-embedding-3-small
|
|
87
|
+
anthropic: 1024, # voyage embeddings
|
|
88
|
+
gemini: 768, # text-embedding-004
|
|
89
|
+
azure: 1536, # same as OpenAI
|
|
90
|
+
ollama: 768, # nomic-embed-text
|
|
91
|
+
huggingface: 768, # varies by model
|
|
92
|
+
openrouter: 1536, # varies by model
|
|
93
|
+
bedrock: 1536, # titan-embed-text
|
|
94
|
+
deepseek: 1536 # varies by model
|
|
95
|
+
}.freeze
|
|
96
|
+
|
|
45
97
|
def initialize
|
|
46
|
-
# Default configuration
|
|
98
|
+
# Default configuration - Ollama for local development
|
|
47
99
|
@embedding_provider = :ollama
|
|
48
|
-
@embedding_model = 'nomic-embed-text'
|
|
100
|
+
@embedding_model = 'nomic-embed-text:latest' # Include tag for Ollama models
|
|
49
101
|
@embedding_dimensions = 768
|
|
50
102
|
|
|
51
103
|
@tag_provider = :ollama
|
|
52
|
-
@tag_model = '
|
|
53
|
-
|
|
54
|
-
|
|
104
|
+
@tag_model = 'gemma3:latest' # Include tag for Ollama models
|
|
105
|
+
|
|
106
|
+
# Provider credentials from environment variables
|
|
107
|
+
@openai_api_key = ENV['OPENAI_API_KEY']
|
|
108
|
+
@openai_organization = ENV['OPENAI_ORGANIZATION']
|
|
109
|
+
@openai_project = ENV['OPENAI_PROJECT']
|
|
110
|
+
@anthropic_api_key = ENV['ANTHROPIC_API_KEY']
|
|
111
|
+
@gemini_api_key = ENV['GEMINI_API_KEY']
|
|
112
|
+
@azure_api_key = ENV['AZURE_OPENAI_API_KEY']
|
|
113
|
+
@azure_endpoint = ENV['AZURE_OPENAI_ENDPOINT']
|
|
114
|
+
@azure_api_version = ENV['AZURE_OPENAI_API_VERSION'] || '2024-02-01'
|
|
115
|
+
@ollama_url = ENV['OLLAMA_API_BASE'] || ENV['OLLAMA_URL'] || 'http://localhost:11434'
|
|
116
|
+
@huggingface_api_key = ENV['HUGGINGFACE_API_KEY']
|
|
117
|
+
@openrouter_api_key = ENV['OPENROUTER_API_KEY']
|
|
118
|
+
@bedrock_access_key = ENV['AWS_ACCESS_KEY_ID']
|
|
119
|
+
@bedrock_secret_key = ENV['AWS_SECRET_ACCESS_KEY']
|
|
120
|
+
@bedrock_region = ENV['AWS_REGION'] || 'us-east-1'
|
|
121
|
+
@deepseek_api_key = ENV['DEEPSEEK_API_KEY']
|
|
55
122
|
|
|
56
123
|
# Timeout settings (in seconds) - apply to all LLM providers
|
|
57
124
|
@embedding_timeout = 120 # 2 minutes for embedding generation
|
|
@@ -96,6 +163,76 @@ class HTM
|
|
|
96
163
|
unless [:active_job, :sidekiq, :inline, :thread].include?(@job_backend)
|
|
97
164
|
raise HTM::ValidationError, "job_backend must be one of: :active_job, :sidekiq, :inline, :thread (got #{@job_backend.inspect})"
|
|
98
165
|
end
|
|
166
|
+
|
|
167
|
+
# Validate provider if specified
|
|
168
|
+
if @embedding_provider && !SUPPORTED_PROVIDERS.include?(@embedding_provider)
|
|
169
|
+
raise HTM::ValidationError, "embedding_provider must be one of: #{SUPPORTED_PROVIDERS.join(', ')} (got #{@embedding_provider.inspect})"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
if @tag_provider && !SUPPORTED_PROVIDERS.include?(@tag_provider)
|
|
173
|
+
raise HTM::ValidationError, "tag_provider must be one of: #{SUPPORTED_PROVIDERS.join(', ')} (got #{@tag_provider.inspect})"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Normalize Ollama model name to include tag if missing
|
|
178
|
+
#
|
|
179
|
+
# Ollama models require a tag (e.g., :latest, :7b, :13b). If the user
|
|
180
|
+
# specifies a model without a tag, we append :latest by default.
|
|
181
|
+
#
|
|
182
|
+
# @param model_name [String] Original model name
|
|
183
|
+
# @return [String] Normalized model name with tag
|
|
184
|
+
#
|
|
185
|
+
def normalize_ollama_model(model_name)
|
|
186
|
+
return model_name if model_name.nil? || model_name.empty?
|
|
187
|
+
return model_name if model_name.include?(':')
|
|
188
|
+
|
|
189
|
+
"#{model_name}:latest"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Configure RubyLLM with the appropriate provider credentials
|
|
193
|
+
#
|
|
194
|
+
# @param provider [Symbol] The provider to configure (:openai, :anthropic, etc.)
|
|
195
|
+
#
|
|
196
|
+
def configure_ruby_llm(provider = nil)
|
|
197
|
+
require 'ruby_llm' unless defined?(RubyLLM)
|
|
198
|
+
|
|
199
|
+
provider ||= @embedding_provider
|
|
200
|
+
|
|
201
|
+
RubyLLM.configure do |config|
|
|
202
|
+
case provider
|
|
203
|
+
when :openai
|
|
204
|
+
config.openai_api_key = @openai_api_key if @openai_api_key
|
|
205
|
+
config.openai_organization = @openai_organization if @openai_organization && config.respond_to?(:openai_organization=)
|
|
206
|
+
config.openai_project = @openai_project if @openai_project && config.respond_to?(:openai_project=)
|
|
207
|
+
when :anthropic
|
|
208
|
+
config.anthropic_api_key = @anthropic_api_key if @anthropic_api_key
|
|
209
|
+
when :gemini
|
|
210
|
+
config.gemini_api_key = @gemini_api_key if @gemini_api_key
|
|
211
|
+
when :azure
|
|
212
|
+
config.azure_api_key = @azure_api_key if @azure_api_key && config.respond_to?(:azure_api_key=)
|
|
213
|
+
config.azure_endpoint = @azure_endpoint if @azure_endpoint && config.respond_to?(:azure_endpoint=)
|
|
214
|
+
config.azure_api_version = @azure_api_version if @azure_api_version && config.respond_to?(:azure_api_version=)
|
|
215
|
+
when :ollama
|
|
216
|
+
# Ollama exposes OpenAI-compatible API at /v1
|
|
217
|
+
# Ensure URL has /v1 suffix (add if missing, don't duplicate if present)
|
|
218
|
+
ollama_api_base = if @ollama_url.end_with?('/v1') || @ollama_url.end_with?('/v1/')
|
|
219
|
+
@ollama_url.sub(%r{/+$}, '') # Just remove trailing slashes
|
|
220
|
+
else
|
|
221
|
+
"#{@ollama_url.sub(%r{/+$}, '')}/v1"
|
|
222
|
+
end
|
|
223
|
+
config.ollama_api_base = ollama_api_base
|
|
224
|
+
when :huggingface
|
|
225
|
+
config.huggingface_api_key = @huggingface_api_key if @huggingface_api_key && config.respond_to?(:huggingface_api_key=)
|
|
226
|
+
when :openrouter
|
|
227
|
+
config.openrouter_api_key = @openrouter_api_key if @openrouter_api_key && config.respond_to?(:openrouter_api_key=)
|
|
228
|
+
when :bedrock
|
|
229
|
+
config.bedrock_api_key = @bedrock_access_key if @bedrock_access_key && config.respond_to?(:bedrock_api_key=)
|
|
230
|
+
config.bedrock_secret_key = @bedrock_secret_key if @bedrock_secret_key && config.respond_to?(:bedrock_secret_key=)
|
|
231
|
+
config.bedrock_region = @bedrock_region if @bedrock_region && config.respond_to?(:bedrock_region=)
|
|
232
|
+
when :deepseek
|
|
233
|
+
config.deepseek_api_key = @deepseek_api_key if @deepseek_api_key && config.respond_to?(:deepseek_api_key=)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
99
236
|
end
|
|
100
237
|
|
|
101
238
|
private
|
|
@@ -153,50 +290,93 @@ class HTM
|
|
|
153
290
|
end
|
|
154
291
|
end
|
|
155
292
|
|
|
156
|
-
# Default embedding generator using
|
|
293
|
+
# Default embedding generator using RubyLLM
|
|
157
294
|
#
|
|
158
295
|
# @return [Proc] Callable that takes text and returns embedding vector
|
|
159
296
|
#
|
|
160
297
|
def default_embedding_generator
|
|
161
298
|
lambda do |text|
|
|
162
|
-
require '
|
|
163
|
-
require 'json'
|
|
299
|
+
require 'ruby_llm' unless defined?(RubyLLM)
|
|
164
300
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
uri = URI("#{@ollama_url}/api/embeddings")
|
|
168
|
-
request = Net::HTTP::Post.new(uri)
|
|
169
|
-
request['Content-Type'] = 'application/json'
|
|
170
|
-
request.body = { model: @embedding_model, prompt: text }.to_json
|
|
171
|
-
|
|
172
|
-
response = Net::HTTP.start(uri.hostname, uri.port,
|
|
173
|
-
read_timeout: @embedding_timeout,
|
|
174
|
-
open_timeout: @connection_timeout) do |http|
|
|
175
|
-
http.request(request)
|
|
176
|
-
end
|
|
301
|
+
# Configure RubyLLM for the embedding provider
|
|
302
|
+
configure_ruby_llm(@embedding_provider)
|
|
177
303
|
|
|
178
|
-
|
|
179
|
-
|
|
304
|
+
# Refresh models for Ollama to discover local models
|
|
305
|
+
if @embedding_provider == :ollama && !@ollama_models_refreshed
|
|
306
|
+
RubyLLM.models.refresh!
|
|
307
|
+
@ollama_models_refreshed = true
|
|
308
|
+
end
|
|
180
309
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
end
|
|
310
|
+
# Normalize Ollama model name (ensure it has a tag like :latest)
|
|
311
|
+
model = @embedding_provider == :ollama ? normalize_ollama_model(@embedding_model) : @embedding_model
|
|
184
312
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
313
|
+
# Generate embedding using RubyLLM
|
|
314
|
+
response = RubyLLM.embed(text, model: model)
|
|
315
|
+
|
|
316
|
+
# Extract embedding vector from response
|
|
317
|
+
embedding = extract_embedding_from_response(response)
|
|
318
|
+
|
|
319
|
+
unless embedding.is_a?(Array) && embedding.all? { |v| v.is_a?(Numeric) }
|
|
320
|
+
raise HTM::EmbeddingError, "Invalid embedding response format from #{@embedding_provider}"
|
|
188
321
|
end
|
|
322
|
+
|
|
323
|
+
embedding
|
|
189
324
|
end
|
|
190
325
|
end
|
|
191
326
|
|
|
192
|
-
#
|
|
327
|
+
# Extract embedding vector from RubyLLM response
|
|
328
|
+
#
|
|
329
|
+
# @param response [Object] RubyLLM embed response
|
|
330
|
+
# @return [Array<Float>] Embedding vector
|
|
331
|
+
#
|
|
332
|
+
def extract_embedding_from_response(response)
|
|
333
|
+
return nil unless response
|
|
334
|
+
|
|
335
|
+
# Handle different response formats from RubyLLM
|
|
336
|
+
case response
|
|
337
|
+
when Array
|
|
338
|
+
# Direct array response
|
|
339
|
+
response
|
|
340
|
+
when ->(r) { r.respond_to?(:vectors) }
|
|
341
|
+
# RubyLLM::Embedding object with vectors method
|
|
342
|
+
vectors = response.vectors
|
|
343
|
+
vectors.is_a?(Array) && vectors.first.is_a?(Array) ? vectors.first : vectors
|
|
344
|
+
when ->(r) { r.respond_to?(:to_a) }
|
|
345
|
+
# Can be converted to array
|
|
346
|
+
response.to_a
|
|
347
|
+
when ->(r) { r.respond_to?(:embedding) }
|
|
348
|
+
# Has embedding attribute
|
|
349
|
+
response.embedding
|
|
350
|
+
else
|
|
351
|
+
# Try to extract vectors from instance variables
|
|
352
|
+
if response.respond_to?(:instance_variable_get)
|
|
353
|
+
vectors = response.instance_variable_get(:@vectors)
|
|
354
|
+
return vectors.first if vectors.is_a?(Array) && vectors.first.is_a?(Array)
|
|
355
|
+
return vectors if vectors.is_a?(Array)
|
|
356
|
+
end
|
|
357
|
+
raise HTM::EmbeddingError, "Cannot extract embedding from response: #{response.class}"
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Default tag extractor using RubyLLM chat
|
|
193
362
|
#
|
|
194
363
|
# @return [Proc] Callable that takes text and ontology, returns array of tags
|
|
195
364
|
#
|
|
196
365
|
def default_tag_extractor
|
|
197
366
|
lambda do |text, existing_ontology = []|
|
|
198
|
-
require '
|
|
199
|
-
|
|
367
|
+
require 'ruby_llm' unless defined?(RubyLLM)
|
|
368
|
+
|
|
369
|
+
# Configure RubyLLM for the tag provider
|
|
370
|
+
configure_ruby_llm(@tag_provider)
|
|
371
|
+
|
|
372
|
+
# Refresh models for Ollama to discover local models
|
|
373
|
+
if @tag_provider == :ollama && !@ollama_models_refreshed
|
|
374
|
+
RubyLLM.models.refresh!
|
|
375
|
+
@ollama_models_refreshed = true
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Normalize Ollama model name (ensure it has a tag like :latest)
|
|
379
|
+
model = @tag_provider == :ollama ? normalize_ollama_model(@tag_model) : @tag_model
|
|
200
380
|
|
|
201
381
|
# Build prompt
|
|
202
382
|
ontology_context = if existing_ontology.any?
|
|
@@ -225,41 +405,48 @@ class HTM
|
|
|
225
405
|
Return ONLY the topic tags, one per line, no explanations.
|
|
226
406
|
PROMPT
|
|
227
407
|
|
|
228
|
-
|
|
229
|
-
when :ollama
|
|
230
|
-
uri = URI("#{@ollama_url}/api/generate")
|
|
231
|
-
request = Net::HTTP::Post.new(uri)
|
|
232
|
-
request['Content-Type'] = 'application/json'
|
|
233
|
-
request.body = {
|
|
234
|
-
model: @tag_model,
|
|
235
|
-
prompt: prompt,
|
|
236
|
-
system: 'You are a precise topic extraction system. Output only topic tags in hierarchical format: root:subtopic:detail',
|
|
237
|
-
stream: false,
|
|
238
|
-
options: { temperature: 0 }
|
|
239
|
-
}.to_json
|
|
240
|
-
|
|
241
|
-
response = Net::HTTP.start(uri.hostname, uri.port,
|
|
242
|
-
read_timeout: @tag_timeout,
|
|
243
|
-
open_timeout: @connection_timeout) do |http|
|
|
244
|
-
http.request(request)
|
|
245
|
-
end
|
|
408
|
+
system_prompt = 'You are a precise topic extraction system. Output only topic tags in hierarchical format: root:subtopic:detail'
|
|
246
409
|
|
|
247
|
-
|
|
248
|
-
|
|
410
|
+
# Use RubyLLM chat for tag extraction
|
|
411
|
+
chat = RubyLLM.chat(model: model)
|
|
412
|
+
chat.with_instructions(system_prompt)
|
|
413
|
+
response = chat.ask(prompt)
|
|
249
414
|
|
|
250
|
-
|
|
251
|
-
|
|
415
|
+
# Extract text from response
|
|
416
|
+
response_text = extract_text_from_response(response)
|
|
252
417
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
tag =~ /^[a-z0-9\-]+(:[a-z0-9\-]+)*$/
|
|
256
|
-
end
|
|
418
|
+
# Parse and validate tags
|
|
419
|
+
tags = response_text.to_s.split("\n").map(&:strip).reject(&:empty?)
|
|
257
420
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
raise HTM::TagError, "Unsupported tag provider: #{@tag_provider}. Only :ollama is currently supported."
|
|
421
|
+
# Validate format: lowercase alphanumeric + hyphens + colons
|
|
422
|
+
valid_tags = tags.select do |tag|
|
|
423
|
+
tag =~ /^[a-z0-9\-]+(:[a-z0-9\-]+)*$/
|
|
262
424
|
end
|
|
425
|
+
|
|
426
|
+
# Limit depth to 5 levels (4 colons maximum)
|
|
427
|
+
valid_tags.select { |tag| tag.count(':') < 5 }
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Extract text content from RubyLLM chat response
|
|
432
|
+
#
|
|
433
|
+
# @param response [Object] RubyLLM chat response
|
|
434
|
+
# @return [String] Response text
|
|
435
|
+
#
|
|
436
|
+
def extract_text_from_response(response)
|
|
437
|
+
return '' unless response
|
|
438
|
+
|
|
439
|
+
case response
|
|
440
|
+
when String
|
|
441
|
+
response
|
|
442
|
+
when ->(r) { r.respond_to?(:content) }
|
|
443
|
+
response.content.to_s
|
|
444
|
+
when ->(r) { r.respond_to?(:text) }
|
|
445
|
+
response.text.to_s
|
|
446
|
+
when ->(r) { r.respond_to?(:to_s) }
|
|
447
|
+
response.to_s
|
|
448
|
+
else
|
|
449
|
+
''
|
|
263
450
|
end
|
|
264
451
|
end
|
|
265
452
|
end
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# HTM Sinatra Integration
|
|
4
|
+
#
|
|
5
|
+
# Optional integration for using HTM with Sinatra web applications.
|
|
6
|
+
# This file is NOT loaded automatically - require it explicitly:
|
|
7
|
+
#
|
|
8
|
+
# require 'htm'
|
|
9
|
+
# require 'htm/integrations/sinatra'
|
|
10
|
+
#
|
|
11
|
+
# Provides:
|
|
12
|
+
# - HTM::Sinatra::Helpers - Request helpers (init_htm, htm, remember, recall)
|
|
13
|
+
# - HTM::Sinatra::Middleware - Connection pool management
|
|
14
|
+
# - Sinatra::Base.register_htm - One-line setup
|
|
15
|
+
#
|
|
16
|
+
|
|
3
17
|
require 'sinatra/base'
|
|
4
18
|
|
|
5
19
|
class HTM
|
|
@@ -8,20 +22,22 @@ class HTM
|
|
|
8
22
|
# Provides convenient helper methods for using HTM in Sinatra applications.
|
|
9
23
|
#
|
|
10
24
|
# @example Basic usage
|
|
25
|
+
# require 'htm/integrations/sinatra'
|
|
26
|
+
#
|
|
11
27
|
# class MyApp < Sinatra::Base
|
|
12
|
-
#
|
|
28
|
+
# register_htm
|
|
13
29
|
#
|
|
14
30
|
# before do
|
|
15
31
|
# init_htm(robot_name: session[:user_id] || 'guest')
|
|
16
32
|
# end
|
|
17
33
|
#
|
|
18
34
|
# post '/remember' do
|
|
19
|
-
# node_id =
|
|
35
|
+
# node_id = remember(params[:content])
|
|
20
36
|
# json status: 'ok', node_id: node_id
|
|
21
37
|
# end
|
|
22
38
|
#
|
|
23
39
|
# get '/recall' do
|
|
24
|
-
# memories =
|
|
40
|
+
# memories = recall(params[:topic], limit: 10)
|
|
25
41
|
# json memories: memories
|
|
26
42
|
# end
|
|
27
43
|
# end
|
|
@@ -53,11 +69,11 @@ class HTM
|
|
|
53
69
|
# Remember information (convenience method)
|
|
54
70
|
#
|
|
55
71
|
# @param content [String] Content to remember
|
|
56
|
-
# @param
|
|
72
|
+
# @param tags [Array<String>] Optional tags to assign
|
|
57
73
|
# @return [Integer] Node ID
|
|
58
74
|
#
|
|
59
|
-
def remember(content,
|
|
60
|
-
htm.remember(content,
|
|
75
|
+
def remember(content, tags: [])
|
|
76
|
+
htm.remember(content, tags: tags)
|
|
61
77
|
end
|
|
62
78
|
|
|
63
79
|
# Recall memories (convenience method)
|
|
@@ -91,16 +107,18 @@ class HTM
|
|
|
91
107
|
# end
|
|
92
108
|
#
|
|
93
109
|
class Middleware
|
|
110
|
+
# Class-level storage for connection configuration (shared across threads)
|
|
111
|
+
@@db_config = nil
|
|
112
|
+
@@config_mutex = Mutex.new
|
|
113
|
+
|
|
94
114
|
def initialize(app, options = {})
|
|
95
115
|
@app = app
|
|
96
116
|
@options = options
|
|
97
117
|
end
|
|
98
118
|
|
|
99
119
|
def call(env)
|
|
100
|
-
#
|
|
101
|
-
|
|
102
|
-
HTM::ActiveRecordConfig.establish_connection!
|
|
103
|
-
end
|
|
120
|
+
# Ensure connection is available in this thread
|
|
121
|
+
ensure_thread_connection!
|
|
104
122
|
|
|
105
123
|
# Process request
|
|
106
124
|
status, headers, body = @app.call(env)
|
|
@@ -108,8 +126,55 @@ class HTM
|
|
|
108
126
|
# Return response
|
|
109
127
|
[status, headers, body]
|
|
110
128
|
ensure
|
|
111
|
-
# Return connections to pool
|
|
112
|
-
ActiveRecord::Base
|
|
129
|
+
# Return connections to pool after request completes
|
|
130
|
+
if defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:connection_handler)
|
|
131
|
+
ActiveRecord::Base.connection_handler.clear_active_connections!
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Store the connection config at startup (called from register_htm)
|
|
136
|
+
def self.store_config!
|
|
137
|
+
@@config_mutex.synchronize do
|
|
138
|
+
return if @@db_config
|
|
139
|
+
|
|
140
|
+
@@db_config = HTM::ActiveRecordConfig.load_database_config
|
|
141
|
+
HTM.logger.debug "HTM database config stored for thread-safe access"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def ensure_thread_connection!
|
|
148
|
+
# Check if connection pool exists and has an active connection
|
|
149
|
+
pool_exists = begin
|
|
150
|
+
ActiveRecord::Base.connection_pool
|
|
151
|
+
true
|
|
152
|
+
rescue ActiveRecord::ConnectionNotDefined
|
|
153
|
+
false
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
if pool_exists
|
|
157
|
+
return if ActiveRecord::Base.connection_pool.active_connection?
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Re-establish connection using stored config
|
|
161
|
+
if @@db_config
|
|
162
|
+
ActiveRecord::Base.establish_connection(@@db_config)
|
|
163
|
+
HTM.logger.debug "HTM database connection established for request thread"
|
|
164
|
+
else
|
|
165
|
+
raise "HTM database config not stored - call register_htm at app startup"
|
|
166
|
+
end
|
|
167
|
+
rescue ActiveRecord::ConnectionNotDefined, ActiveRecord::ConnectionNotEstablished => e
|
|
168
|
+
# Pool doesn't exist, establish connection
|
|
169
|
+
if @@db_config
|
|
170
|
+
ActiveRecord::Base.establish_connection(@@db_config)
|
|
171
|
+
HTM.logger.debug "HTM database connection established for request thread"
|
|
172
|
+
else
|
|
173
|
+
raise "HTM database config not stored - call register_htm at app startup"
|
|
174
|
+
end
|
|
175
|
+
rescue StandardError => e
|
|
176
|
+
HTM.logger.error "Failed to ensure thread connection: #{e.class} - #{e.message}"
|
|
177
|
+
raise
|
|
113
178
|
end
|
|
114
179
|
end
|
|
115
180
|
end
|
|
@@ -150,6 +215,16 @@ module ::Sinatra
|
|
|
150
215
|
end
|
|
151
216
|
end
|
|
152
217
|
|
|
218
|
+
# Store database config for thread-safe access and establish initial connection
|
|
219
|
+
begin
|
|
220
|
+
HTM::Sinatra::Middleware.store_config!
|
|
221
|
+
HTM::ActiveRecordConfig.establish_connection!
|
|
222
|
+
HTM.logger.info "HTM database connection established"
|
|
223
|
+
rescue StandardError => e
|
|
224
|
+
HTM.logger.error "Failed to establish HTM database connection: #{e.message}"
|
|
225
|
+
raise
|
|
226
|
+
end
|
|
227
|
+
|
|
153
228
|
HTM.logger.info "HTM registered with Sinatra application"
|
|
154
229
|
HTM.logger.debug "HTM job backend: #{HTM.configuration.job_backend}"
|
|
155
230
|
end
|
data/lib/htm/job_adapter.rb
CHANGED
|
@@ -72,7 +72,10 @@ class HTM
|
|
|
72
72
|
|
|
73
73
|
# Convert job class to Sidekiq worker if needed
|
|
74
74
|
sidekiq_class = to_sidekiq_worker(job_class)
|
|
75
|
-
|
|
75
|
+
|
|
76
|
+
# Sidekiq 7.x requires native JSON types - convert symbol keys to strings
|
|
77
|
+
json_params = params.transform_keys(&:to_s)
|
|
78
|
+
sidekiq_class.perform_async(json_params)
|
|
76
79
|
|
|
77
80
|
HTM.logger.debug "Enqueued #{job_class.name} via Sidekiq with params: #{params.inspect}"
|
|
78
81
|
end
|
|
@@ -135,12 +138,16 @@ class HTM
|
|
|
135
138
|
return job_class if job_class.included_modules.include?(Sidekiq::Worker)
|
|
136
139
|
|
|
137
140
|
# Create wrapper Sidekiq worker
|
|
141
|
+
# Note: Sidekiq 7.x requires JSON-compatible args, so we accept a hash
|
|
142
|
+
# and convert string keys back to symbols for the underlying job
|
|
138
143
|
Class.new do
|
|
139
144
|
include Sidekiq::Worker
|
|
140
145
|
sidekiq_options queue: :htm, retry: 3
|
|
141
146
|
|
|
142
|
-
define_method(:perform) do
|
|
143
|
-
|
|
147
|
+
define_method(:perform) do |params|
|
|
148
|
+
# Convert string keys back to symbols for the job class
|
|
149
|
+
symbolized_params = params.transform_keys(&:to_sym)
|
|
150
|
+
job_class.perform(**symbolized_params)
|
|
144
151
|
end
|
|
145
152
|
|
|
146
153
|
# Set descriptive name
|